diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 98b8e25343..24eb2d32b0 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -37,7 +37,7 @@ jobs: run: | sudo apt update sudo apt install build-essential cmake g++ - sudo apt install qtbase5-dev qtbase5-private-dev qttools5-dev qttools5-dev-tools libqt5svg5-dev libargon2-dev libminizip-dev libbotan-2-dev libqrencode-dev zlib1g-dev asciidoctor libreadline-dev libpcsclite-dev libusb-1.0-0-dev libxi-dev libxtst-dev libqt5x11extras5-dev + sudo apt install qtbase5-dev qtbase5-private-dev qttools5-dev qttools5-dev-tools libqt5svg5-dev libargon2-dev libkeyutils-dev libminizip-dev libbotan-2-dev libqrencode-dev zlib1g-dev asciidoctor libreadline-dev libpcsclite-dev libusb-1.0-0-dev libxi-dev libxtst-dev libqt5x11extras5-dev # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL 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/cmake/CLangFormat.cmake b/cmake/CLangFormat.cmake index b2df97d4d3..7984f25286 100644 --- a/cmake/CLangFormat.cmake +++ b/cmake/CLangFormat.cmake @@ -18,7 +18,7 @@ set(EXCLUDED_DIRS src/thirdparty src/zxcvbn # objective-c directories - src/touchid + src/quickunlock/touchid src/autotype/mac src/gui/osutils/macutils) diff --git a/share/CMakeLists.txt b/share/CMakeLists.txt index 90f7e6e683..f120fc6e2c 100644 --- a/share/CMakeLists.txt +++ b/share/CMakeLists.txt @@ -58,7 +58,12 @@ if(UNIX AND NOT APPLE AND NOT HAIKU) EXCLUDE PATTERN "actions" EXCLUDE PATTERN "categories" EXCLUDE) endif(KEEPASSXC_DIST_FLATPAK) configure_file(linux/${APP_ID}.desktop.in ${CMAKE_CURRENT_BINARY_DIR}/linux/${APP_ID}.desktop @ONLY) + configure_file(linux/${APP_ID}.policy.in ${CMAKE_CURRENT_BINARY_DIR}/linux/${APP_ID}.policy @ONLY) + install(FILES ${CMAKE_CURRENT_BINARY_DIR}/linux/${APP_ID}.desktop DESTINATION ${CMAKE_INSTALL_DATADIR}/applications) + if("${CMAKE_SYSTEM}" MATCHES "Linux") + install(FILES ${CMAKE_CURRENT_BINARY_DIR}/linux/${APP_ID}.policy DESTINATION ${CMAKE_INSTALL_DATADIR}/polkit-1/actions) + endif() install(FILES linux/${APP_ID}.appdata.xml DESTINATION ${CMAKE_INSTALL_DATADIR}/metainfo) endif(UNIX AND NOT APPLE AND NOT HAIKU) 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/linux/org.keepassxc.KeePassXC.policy.in b/share/linux/org.keepassxc.KeePassXC.policy.in new file mode 100644 index 0000000000..e5b837e0cc --- /dev/null +++ b/share/linux/org.keepassxc.KeePassXC.policy.in @@ -0,0 +1,18 @@ + + + + KeePassXC Developers + + @APP_ICON_NAME@ + + + Quick Unlock for a KeePassXC Database + Authentication is required to unlock a KeePassXC Database + + no + auth_self + + + diff --git a/share/translations/keepassxc_en.ts b/share/translations/keepassxc_en.ts index 04ce9a499e..52237dd2a1 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,65 @@ 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 + + + + Register new + + + + Register + + + + Timeout in <b>%n</b> seconds... + + + + + + + Do you want to register Passkey for: + + + + %1 (%2) + + + + Existing Passkey found. +Do you want to register a new Passkey for: + + + + Select the existing Passkey and press Update to replace it. + + + + Authenticate Passkey credentials for: + + BrowserService @@ -900,6 +955,10 @@ Do you want to delete the entry? + + %1 (Passkey) + + BrowserSettingsWidget @@ -1518,10 +1577,6 @@ To prevent this error from appearing, you must go to "Database Settings / S Retry with empty password - - Failed to authenticate with Touch ID - - Failed to open key file: %1 @@ -1576,11 +1631,7 @@ If you do not have a key file, please leave the field empty. - Failed to authenticate with Windows Hello: %1 - - - - Windows Hello setup was canceled or failed. Quick unlock has not been enabled. + Failed to authenticate with Quick Unlock: %1 @@ -5482,6 +5533,18 @@ We recommend you use the AppImage available on our downloads page. Allow Screen Capture + + Passkeys… + + + + Passkeys + + + + Import Passkey + + ManageDatabase @@ -5867,6 +5930,152 @@ 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. + + + + Cannot write to file + + + + + 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 + + + + Database: %1 + + + + Group: + + + + + 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 @@ -7983,6 +8192,66 @@ Kernel: %3 %4 allow screenshots and app recording (Windows/macOS) + + AES initialization failed + + + + AES encrypt failed + + + + Failed to store in Linux Keyring + + + + Could not locate key in keyring + + + + Could not read key in keyring + + + + AES decrypt failed + + + + No Polkit authentication agent was available + + + + Polkit authorization failed + + + + No Quick Unlock provider is available + + + + Polkit returned an error: %1 + + + + Failed to init KeePassXC crypto. + + + + Failed to encrypt key data. + + + + Failed to get Windows Hello credential. + + + + Failed to decrypt key data. + + + + Passkeys + + QtIOCompressor @@ -8020,18 +8289,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. @@ -8099,17 +8356,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. @@ -8188,6 +8453,18 @@ Kernel: %3 %4 Exclude from reports + + Show expired entries + + + + Show entries that have been excluded from reports + + + + (Expired) + + ReportsWidgetHibp @@ -8287,6 +8564,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 @@ -8998,25 +9337,6 @@ Example: JBSWY3DPEHPK3PXP - - WindowsHello - - Failed to init KeePassXC crypto. - - - - Failed to encrypt key data. - - - - Failed to get Windows Hello credential. - - - - Failed to decrypt key data. - - - YubiKey diff --git a/share/windows/wix-template.xml b/share/windows/wix-template.xml index ae937ce709..add2af2974 100644 --- a/share/windows/wix-template.xml +++ b/share/windows/wix-template.xml @@ -92,6 +92,9 @@ + + + @@ -116,12 +119,17 @@ - - - + + + + + + + + diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index b8099eed59..15b6d947ac 100644 --- a/src/CMakeLists.txt +++ b/src/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 @@ -60,6 +60,7 @@ set(keepassx_SOURCES core/TimeInfo.cpp core/Tools.cpp core/Translator.cpp + core/UrlTools.cpp cli/Utils.cpp cli/TextStream.cpp crypto/Crypto.cpp @@ -193,6 +194,7 @@ set(keepassx_SOURCES streams/qtiocompressor.cpp streams/StoreDataStream.cpp streams/SymmetricCipherStream.cpp + quickunlock/QuickUnlockInterface.cpp totp/totp.cpp) if(APPLE) set(keepassx_SOURCES @@ -208,6 +210,12 @@ if(UNIX AND NOT APPLE) ${keepassx_SOURCES} gui/osutils/nixutils/ScreenLockListenerDBus.cpp gui/osutils/nixutils/NixUtils.cpp) + if("${CMAKE_SYSTEM}" MATCHES "Linux") + set(keepassx_SOURCES + ${keepassx_SOURCES} + quickunlock/Polkit.cpp + quickunlock/PolkitDbusTypes.cpp) + endif() if(WITH_XC_X11) list(APPEND keepassx_SOURCES gui/osutils/nixutils/X11Funcs.cpp) @@ -216,6 +224,21 @@ if(UNIX AND NOT APPLE) gui/org.keepassxc.KeePassXC.MainWindow.xml gui/MainWindow.h MainWindow) + + set_source_files_properties( + quickunlock/dbus/org.freedesktop.PolicyKit1.Authority.xml + PROPERTIES + INCLUDE "quickunlock/PolkitDbusTypes.h" + ) + qt5_add_dbus_interface(keepassx_SOURCES + quickunlock/dbus/org.freedesktop.PolicyKit1.Authority.xml + polkit_dbus + ) + + find_library(KEYUTILS_LIBRARIES NAMES keyutils) + if(NOT KEYUTILS_LIBRARIES) + message(FATAL_ERROR "Could not find libkeyutils") + endif() endif() if(WIN32) set(keepassx_SOURCES @@ -223,7 +246,7 @@ if(WIN32) gui/osutils/winutils/ScreenLockListenerWin.cpp gui/osutils/winutils/WinUtils.cpp) if (MSVC) - list(APPEND keepassx_SOURCES winhello/WindowsHello.cpp) + list(APPEND keepassx_SOURCES quickunlock/WindowsHello.cpp) endif() endif() @@ -236,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") @@ -248,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) @@ -292,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() @@ -315,9 +350,9 @@ if(WITH_XC_NETWORKING) endif() if(APPLE) - list(APPEND keepassx_SOURCES touchid/TouchID.mm) + list(APPEND keepassx_SOURCES quickunlock/TouchID.mm) # TODO: Remove -Wno-error once deprecation warnings have been resolved. - set_source_files_properties(touchid/TouchID.mm PROPERTY COMPILE_FLAGS "-Wno-old-style-cast") + set_source_files_properties(quickunlock/TouchID.mm PROPERTY COMPILE_FLAGS "-Wno-old-style-cast") endif() configure_file(config-keepassx.h.cmake ${CMAKE_CURRENT_BINARY_DIR}/config-keepassx.h) @@ -343,6 +378,7 @@ target_link_libraries(keepassx_core ${ZXCVBN_LIBRARIES} ${ZLIB_LIBRARIES} ${ARGON2_LIBRARIES} + ${KEYUTILS_LIBRARIES} ${thirdparty_LIBRARIES} ) 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..bcc7043ce0 --- /dev/null +++ b/src/browser/BrowserCbor.cpp @@ -0,0 +1,253 @@ +/* + * 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 "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..9fcb685335 --- /dev/null +++ b/src/browser/BrowserCbor.h @@ -0,0 +1,70 @@ +/* + * 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_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..af62488f16 --- /dev/null +++ b/src/browser/BrowserPasskeysConfirmationDialog.cpp @@ -0,0 +1,153 @@ +/* + * 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->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->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); + } 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->firstLabel->setText(tr("Authenticate Passkey credentials for:")); + m_ui->dataLabel->setText(origin); + m_ui->secondLabel->setText(""); + 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..5b566d725b --- /dev/null +++ b/src/browser/BrowserPasskeysConfirmationDialog.ui @@ -0,0 +1,159 @@ + + + BrowserPasskeysConfirmationDialog + + + + 0 + 0 + 405 + 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::AlignTop + + + + + + + Qt::Vertical + + + QSizePolicy::Expanding + + + + 20 + 10 + + + + + + + + + 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 b412409a59..0a2ec6eaf3 100644 --- a/src/browser/BrowserService.cpp +++ b/src/browser/BrowserService.cpp @@ -25,9 +25,14 @@ #include "BrowserMessageBuilder.h" #include "BrowserSettings.h" #include "core/Tools.h" +#include "core/UrlTools.h" #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 @@ -47,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"); @@ -115,7 +123,7 @@ bool BrowserService::openDatabase(bool triggerUnlock) return true; } - if (triggerUnlock) { + if (triggerUnlock && !m_bringToFrontRequested) { m_bringToFrontRequested = true; updateWindowState(); emit requestUnlock(); @@ -544,33 +552,6 @@ bool BrowserService::isPasswordGeneratorRequested() const return m_passwordGeneratorRequested; } -// Returns true if URLs are identical. Paths with "/" are removed during comparison. -// URLs without scheme reverts to https. -// Special handling is needed because QUrl::matches() with QUrl::StripTrailingSlash does not strip "/" paths. -bool BrowserService::isUrlIdentical(const QString& first, const QString& second) const -{ - auto trimUrl = [](QString url) { - url = url.trimmed(); - if (url.endsWith("/")) { - url.remove(url.length() - 1, 1); - } - - return url; - }; - - if (first.isEmpty() || second.isEmpty()) { - return false; - } - - const auto firstUrl = trimUrl(first); - const auto secondUrl = trimUrl(second); - if (firstUrl == secondUrl) { - return true; - } - - return QUrl(firstUrl).matches(QUrl(secondUrl), QUrl::StripTrailingSlash); -} - QString BrowserService::storeKey(const QString& key) { auto db = getDatabase(); @@ -633,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(tr("%1 (Passkey)").arg(rpName)); + 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, @@ -647,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); @@ -693,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); @@ -727,8 +879,7 @@ bool BrowserService::updateEntry(const EntryParameters& entryParameters, const Q tr("Do you want to update the information in %1 - %2?") .arg(QUrl(entryParameters.siteUrl).host(), username), MessageBox::Save | MessageBox::Cancel, - MessageBox::Cancel, - MessageBox::Raise); + MessageBox::Cancel); } if (browserSettings()->alwaysAllowUpdate() || dialogResult == MessageBox::Save) { @@ -773,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(); @@ -798,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)) { @@ -812,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) { @@ -847,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)); @@ -1080,18 +1242,6 @@ int BrowserService::sortPriority(const QStringList& urls, const QString& siteUrl return *std::max_element(priorityList.begin(), priorityList.end()); } -bool BrowserService::schemeFound(const QString& url) -{ - QUrl address(url); - return !address.scheme().isEmpty(); -} - -bool BrowserService::isIpAddress(const QString& host) const -{ - QHostAddress address(host); - return address.protocol() == QAbstractSocket::IPv4Protocol || address.protocol() == QAbstractSocket::IPv6Protocol; -} - bool BrowserService::removeFirstDomain(QString& hostname) { int pos = hostname.indexOf("."); @@ -1133,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, @@ -1187,7 +1405,7 @@ bool BrowserService::handleURL(const QString& entryUrl, } // Match the base domain - if (getTopLevelDomainFromUrl(siteQUrl.host()) != getTopLevelDomainFromUrl(entryQUrl.host())) { + if (urlTools()->getBaseDomainFromUrl(siteQUrl.host()) != urlTools()->getBaseDomainFromUrl(entryQUrl.host())) { return false; } @@ -1197,34 +1415,6 @@ bool BrowserService::handleURL(const QString& entryUrl, } return false; -}; - -/** - * Gets the base domain of URL. - * - * Returns the base domain, e.g. https://another.example.co.uk -> example.co.uk - */ -QString BrowserService::getTopLevelDomainFromUrl(const QString& url) const -{ - QUrl qurl = QUrl::fromUserInput(url); - QString host = qurl.host(); - - // If the hostname is an IP address, return it directly - if (isIpAddress(host)) { - return host; - } - - if (host.isEmpty() || !host.contains(qurl.topLevelDomain())) { - return {}; - } - - // Remove the top level domain part from the hostname, e.g. https://another.example.co.uk -> https://another.example - host.chop(qurl.topLevelDomain().length()); - // Split the URL and select the last part, e.g. https://another.example -> example - QString baseDomain = host.split('.').last(); - // Append the top level domain back to the URL, e.g. example -> example.co.uk - baseDomain.append(qurl.topLevelDomain()); - return baseDomain; } QSharedPointer BrowserService::getDatabase() diff --git a/src/browser/BrowserService.h b/src/browser/BrowserService.h index 46bffef014..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; @@ -83,7 +85,29 @@ class BrowserService : public QObject 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, @@ -130,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, @@ -146,18 +174,25 @@ private slots: Group* getDefaultEntryGroup(const QSharedPointer& selectedDb = {}); int sortPriority(const QStringList& urls, const QString& siteUrl, const QString& formUrl); bool schemeFound(const QString& url); - bool isIpAddress(const QString& host) const; 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); - QString getTopLevelDomainFromUrl(const QString& url) const; - QString baseDomain(const QString& hostname) const; QSharedPointer getDatabase(); - QSharedPointer selectedDatabase(); QString getDatabaseRootUuid(); QString getDatabaseRecycleBinUuid(); bool checkLegacySettings(QSharedPointer db); @@ -180,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() @@ -187,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/Database.cpp b/src/core/Database.cpp index 488cc2e4e7..aa36dad126 100644 --- a/src/core/Database.cpp +++ b/src/core/Database.cpp @@ -113,6 +113,8 @@ bool Database::open(QSharedPointer key, QString* error) * Unless `readOnly` is set to false, the database will be opened in * read-write mode and fall back to read-only if that is not possible. * + * If key is provided as null, only headers will be read. + * * @param filePath path to the file * @param key composite key for unlocking the database * @param error error message in case of failure @@ -996,3 +998,14 @@ void Database::stopModifiedTimer() { QMetaObject::invokeMethod(&m_modifiedTimer, "stop"); } + +QUuid Database::publicUuid() +{ + + if (!publicCustomData().contains("KPXC_PUBLIC_UUID")) { + publicCustomData().insert("KPXC_PUBLIC_UUID", QUuid::createUuid().toRfc4122()); + markAsModified(); + } + + return QUuid::fromRfc4122(publicCustomData()["KPXC_PUBLIC_UUID"].toByteArray()); +} diff --git a/src/core/Database.h b/src/core/Database.h index 6d8e0403bf..d4a0a7bd51 100644 --- a/src/core/Database.h +++ b/src/core/Database.h @@ -102,6 +102,7 @@ class Database : public ModifiableObject bool hasNonDataChanges() const; bool isSaving(); + QUuid publicUuid(); QUuid uuid() const; QString filePath() const; QString canonicalFilePath() const; 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 6577971169..cefb0448d9 100644 --- a/src/core/Tools.cpp +++ b/src/core/Tools.cpp @@ -5,7 +5,7 @@ * Copyright (C) 2020 Klarälvdalens Datakonsult AB, a KDAB Group company, info@kdab.com, * author Giuseppe D'Angelo * Copyright (C) 2021 The Qt Company Ltd. - * 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 @@ -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 @@ -271,35 +274,6 @@ namespace Tools } } - bool checkUrlValid(const QString& urlField) - { - if (urlField.isEmpty() || urlField.startsWith("cmd://", Qt::CaseInsensitive) - || urlField.startsWith("kdbx://", Qt::CaseInsensitive) - || urlField.startsWith("{REF:A", Qt::CaseInsensitive)) { - return true; - } - - QUrl url; - if (urlField.contains("://")) { - url = urlField; - } else { - url = QUrl::fromUserInput(urlField); - } - - if (url.scheme() != "file" && url.host().isEmpty()) { - return false; - } - - // Check for illegal characters. Adds also the wildcard * to the list - QRegularExpression re("[<>\\^`{|}\\*]"); - auto match = re.match(urlField); - if (match.hasMatch()) { - return false; - } - - return true; - } - /**************************************************************************** * * Copyright (C) 2020 Giuseppe D'Angelo . @@ -437,6 +411,16 @@ namespace Tools return subbed; } + QString cleanFilename(QString filename) + { + // Remove forward slash from title on all platforms + filename.replace("/", "_"); + // Remove invalid characters + filename.remove(QRegularExpression("[:*?\"<>|]")); + + 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 a8094d0a30..4316a44e84 100644 --- a/src/core/Tools.h +++ b/src/core/Tools.h @@ -1,6 +1,6 @@ /* * Copyright (C) 2012 Felix Geyer - * 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 @@ -38,12 +38,12 @@ namespace Tools bool isBase64(const QByteArray& ba); void sleep(int ms); void wait(int ms); - bool checkUrlValid(const QString& urlField); QString uuidToHex(const QUuid& uuid); QUuid hexToUuid(const QString& uuid); 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/core/UrlTools.cpp b/src/core/UrlTools.cpp new file mode 100644 index 0000000000..bd6db52718 --- /dev/null +++ b/src/core/UrlTools.cpp @@ -0,0 +1,173 @@ +/* + * 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 "UrlTools.h" +#ifdef WITH_XC_NETWORKING +#include +#include +#include +#endif +#include +#include + +Q_GLOBAL_STATIC(UrlTools, s_urlTools) + +UrlTools* UrlTools::instance() +{ + return s_urlTools; +} + +QUrl UrlTools::convertVariantToUrl(const QVariant& var) const +{ + QUrl url; + if (var.canConvert()) { + url = var.toUrl(); + } + return url; +} + +#ifdef WITH_XC_NETWORKING +QUrl UrlTools::getRedirectTarget(QNetworkReply* reply) const +{ + QVariant var = reply->attribute(QNetworkRequest::RedirectionTargetAttribute); + QUrl url = convertVariantToUrl(var); + return url; +} + +/** + * Gets the base domain of URL or hostname. + * + * Returns the base domain, e.g. https://another.example.co.uk -> example.co.uk + * Up-to-date list can be found: https://publicsuffix.org/list/public_suffix_list.dat + */ +QString UrlTools::getBaseDomainFromUrl(const QString& url) const +{ + auto qUrl = QUrl::fromUserInput(url); + + auto host = qUrl.host(); + if (isIpAddress(host)) { + return host; + } + + const auto tld = getTopLevelDomainFromUrl(qUrl.toString()); + if (tld.isEmpty() || tld.length() + 1 >= host.length()) { + return host; + } + + // Remove the top level domain part from the hostname, e.g. https://another.example.co.uk -> https://another.example + host.chop(tld.length() + 1); + // Split the URL and select the last part, e.g. https://another.example -> example + QString baseDomain = host.split('.').last(); + // Append the top level domain back to the URL, e.g. example -> example.co.uk + baseDomain.append(QString(".%1").arg(tld)); + + return baseDomain; +} + +/** + * Gets the top level domain from URL. + * + * Returns the TLD e.g. https://another.example.co.uk -> co.uk + */ +QString UrlTools::getTopLevelDomainFromUrl(const QString& url) const +{ + auto host = QUrl::fromUserInput(url).host(); + if (isIpAddress(host)) { + return host; + } + + const auto numberOfDomainParts = host.split('.').length(); + static const auto dummy = QByteArrayLiteral(""); + + // Only loop the amount of different parts found + for (auto i = 0; i < numberOfDomainParts; ++i) { + // Cut the first part from host + host = host.mid(host.indexOf('.') + 1); + + QNetworkCookie cookie(dummy, dummy); + cookie.setDomain(host); + + // Check if dummy cookie's domain/TLD matches with public suffix list + if (!QNetworkCookieJar{}.setCookiesFromUrl(QList{cookie}, url)) { + return host; + } + } + + return host; +} + +bool UrlTools::isIpAddress(const QString& host) const +{ + QHostAddress address(host); + return address.protocol() == QAbstractSocket::IPv4Protocol || address.protocol() == QAbstractSocket::IPv6Protocol; +} +#endif + +// Returns true if URLs are identical. Paths with "/" are removed during comparison. +// URLs without scheme reverts to https. +// Special handling is needed because QUrl::matches() with QUrl::StripTrailingSlash does not strip "/" paths. +bool UrlTools::isUrlIdentical(const QString& first, const QString& second) const +{ + auto trimUrl = [](QString url) { + url = url.trimmed(); + if (url.endsWith("/")) { + url.remove(url.length() - 1, 1); + } + + return url; + }; + + if (first.isEmpty() || second.isEmpty()) { + return false; + } + + const auto firstUrl = trimUrl(first); + const auto secondUrl = trimUrl(second); + if (firstUrl == secondUrl) { + return true; + } + + return QUrl(firstUrl).matches(QUrl(secondUrl), QUrl::StripTrailingSlash); +} + +bool UrlTools::isUrlValid(const QString& urlField) const +{ + if (urlField.isEmpty() || urlField.startsWith("cmd://", Qt::CaseInsensitive) + || urlField.startsWith("kdbx://", Qt::CaseInsensitive) || urlField.startsWith("{REF:A", Qt::CaseInsensitive)) { + return true; + } + + QUrl url; + if (urlField.contains("://")) { + url = urlField; + } else { + url = QUrl::fromUserInput(urlField); + } + + if (url.scheme() != "file" && url.host().isEmpty()) { + return false; + } + + // Check for illegal characters. Adds also the wildcard * to the list + QRegularExpression re("[<>\\^`{|}\\*]"); + auto match = re.match(urlField); + if (match.hasMatch()) { + return false; + } + + return true; +} diff --git a/src/core/UrlTools.h b/src/core/UrlTools.h new file mode 100644 index 0000000000..c86152d038 --- /dev/null +++ b/src/core/UrlTools.h @@ -0,0 +1,56 @@ +/* + * 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_URLTOOLS_H +#define KEEPASSXC_URLTOOLS_H + +#include "config-keepassx.h" +#include +#include +#include +#include + +class UrlTools : public QObject +{ + Q_OBJECT + +public: + explicit UrlTools() = default; + static UrlTools* instance(); + +#ifdef WITH_XC_NETWORKING + QUrl getRedirectTarget(QNetworkReply* reply) const; + QString getBaseDomainFromUrl(const QString& url) const; + QString getTopLevelDomainFromUrl(const QString& url) const; + bool isIpAddress(const QString& host) const; +#endif + bool isUrlIdentical(const QString& first, const QString& second) const; + bool isUrlValid(const QString& urlField) const; + +private: + QUrl convertVariantToUrl(const QVariant& var) const; + +private: + Q_DISABLE_COPY(UrlTools); +}; + +static inline UrlTools* urlTools() +{ + return UrlTools::instance(); +} + +#endif // KEEPASSXC_URLTOOLS_H diff --git a/src/format/KdbxReader.cpp b/src/format/KdbxReader.cpp index 5610897c84..b552bd1cbe 100644 --- a/src/format/KdbxReader.cpp +++ b/src/format/KdbxReader.cpp @@ -27,6 +27,8 @@ /** * Read KDBX magic header numbers from a device. * + * Passing a null key will only read in the unprotected headers. + * * @param device input device * @param sig1 KDBX signature 1 * @param sig2 KDBX signature 2 @@ -55,6 +57,8 @@ bool KdbxReader::readMagicNumbers(QIODevice* device, quint32& sig1, quint32& sig * Read KDBX stream from device. * The device will automatically be reset to 0 before reading. * + * Passing a null key will only read in the unprotected headers. + * * @param device input device * @param key database encryption composite key * @param db database to read into @@ -91,6 +95,11 @@ bool KdbxReader::readDatabase(QIODevice* device, QSharedPointerEnableCopyOnDoubleClickCheckBox->setChecked( config()->get(Config::Security_EnableCopyOnDoubleClick).toBool()); - bool quickUnlockAvailable = false; -#if defined(Q_OS_MACOS) - quickUnlockAvailable = TouchID::getInstance().isAvailable(); -#elif defined(Q_CC_MSVC) - quickUnlockAvailable = getWindowsHello()->isAvailable(); - connect(getWindowsHello(), &WindowsHello::availableChanged, m_secUi->quickUnlockCheckBox, &QCheckBox::setEnabled); -#endif - m_secUi->quickUnlockCheckBox->setEnabled(quickUnlockAvailable); + m_secUi->quickUnlockCheckBox->setEnabled(getQuickUnlock()->isAvailable()); m_secUi->quickUnlockCheckBox->setChecked(config()->get(Config::Security_QuickUnlock).toBool()); for (const ExtraPage& page : asConst(m_extraPages)) { diff --git a/src/gui/DatabaseOpenWidget.cpp b/src/gui/DatabaseOpenWidget.cpp index 319fdf89ff..1cd176023f 100644 --- a/src/gui/DatabaseOpenWidget.cpp +++ b/src/gui/DatabaseOpenWidget.cpp @@ -26,19 +26,12 @@ #include "gui/MessageBox.h" #include "keys/ChallengeResponseKey.h" #include "keys/FileKey.h" - -#ifdef Q_OS_MACOS -#include "touchid/TouchID.h" -#endif -#ifdef Q_CC_MSVC -#include "winhello/WindowsHello.h" -#endif +#include "quickunlock/QuickUnlockInterface.h" #include #include #include #include - namespace { constexpr int clearFormsDelay = 30000; @@ -46,25 +39,17 @@ namespace bool isQuickUnlockAvailable() { if (config()->get(Config::Security_QuickUnlock).toBool()) { -#if defined(Q_CC_MSVC) - return getWindowsHello()->isAvailable(); -#elif defined(Q_OS_MACOS) - return TouchID::getInstance().isAvailable(); -#endif + return getQuickUnlock()->isAvailable(); } return false; } - bool canPerformQuickUnlock(const QString& filename) + bool canPerformQuickUnlock(const QUuid& dbUuid) { if (isQuickUnlockAvailable()) { -#if defined(Q_CC_MSVC) - return getWindowsHello()->hasKey(filename); -#elif defined(Q_OS_MACOS) - return TouchID::getInstance().containsKey(filename); -#endif + return getQuickUnlock()->hasKey(dbUuid); } - Q_UNUSED(filename); + Q_UNUSED(dbUuid); return false; } } // namespace @@ -149,7 +134,7 @@ void DatabaseOpenWidget::showEvent(QShowEvent* event) DialogyWidget::showEvent(event); if (isOnQuickUnlockScreen()) { m_ui->quickUnlockButton->setFocus(); - if (!canPerformQuickUnlock(m_filename)) { + if (m_db.isNull() || !canPerformQuickUnlock(m_db->publicUuid())) { resetQuickUnlock(); } } else { @@ -178,6 +163,12 @@ void DatabaseOpenWidget::load(const QString& filename) clearForms(); m_filename = filename; + + // Read public headers + QString error; + m_db.reset(new Database()); + m_db->open(m_filename, nullptr, &error); + m_ui->fileNameLabel->setRawText(m_filename); if (config()->get(Config::RememberLastKeyFiles).toBool()) { @@ -187,7 +178,7 @@ void DatabaseOpenWidget::load(const QString& filename) } } - if (canPerformQuickUnlock(m_filename)) { + if (canPerformQuickUnlock(m_db->publicUuid())) { m_ui->centralStack->setCurrentIndex(1); m_ui->quickUnlockButton->setFocus(); } else { @@ -215,7 +206,10 @@ void DatabaseOpenWidget::clearForms() m_ui->keyFileLineEdit->setClearButtonEnabled(true); m_ui->challengeResponseCombo->clear(); m_ui->centralStack->setCurrentIndex(0); - m_db.reset(); + + QString error; + m_db.reset(new Database()); + m_db->open(m_filename, nullptr, &error); } QSharedPointer DatabaseOpenWidget::database() @@ -274,6 +268,8 @@ void DatabaseOpenWidget::openDatabase() msgBox->exec(); if (msgBox->clickedButton() != btn) { m_db.reset(new Database()); + m_db->open(m_filename, nullptr, &error); + m_ui->messageWidget->showMessage(tr("Database unlock canceled."), MessageWidget::MessageType::Error); setUserInteractionLock(false); return; @@ -283,17 +279,7 @@ void DatabaseOpenWidget::openDatabase() // Save Quick Unlock credentials if available if (!blockQuickUnlock && isQuickUnlockAvailable()) { auto keyData = databaseKey->serialize(); -#if defined(Q_CC_MSVC) - // Store the password using Windows Hello - if (!getWindowsHello()->storeKey(m_filename, keyData)) { - getMainWindow()->displayTabMessage( - tr("Windows Hello setup was canceled or failed. Quick unlock has not been enabled."), - MessageWidget::MessageType::Warning); - } -#elif defined(Q_OS_MACOS) - // Store the password using TouchID - TouchID::getInstance().storeKey(m_filename, keyData); -#endif + getQuickUnlock()->setKey(m_db->publicUuid(), keyData); m_ui->messageWidget->hideMessage(); } @@ -338,27 +324,15 @@ QSharedPointer DatabaseOpenWidget::buildDatabaseKey() { auto databaseKey = QSharedPointer::create(); - if (canPerformQuickUnlock(m_filename)) { + if (!m_db.isNull() && canPerformQuickUnlock(m_db->publicUuid())) { // try to retrieve the stored password using Windows Hello QByteArray keyData; -#ifdef Q_CC_MSVC - if (!getWindowsHello()->getKey(m_filename, keyData)) { - // Failed to retrieve Quick Unlock data - auto error = getWindowsHello()->errorString(); - if (!error.isEmpty()) { - m_ui->messageWidget->showMessage(tr("Failed to authenticate with Windows Hello: %1").arg(error), - MessageWidget::Error); - resetQuickUnlock(); - } - return {}; - } -#elif defined(Q_OS_MACOS) - if (!TouchID::getInstance().getKey(m_filename, keyData)) { - // Failed to retrieve Quick Unlock data - m_ui->messageWidget->showMessage(tr("Failed to authenticate with Touch ID"), MessageWidget::Error); + if (!getQuickUnlock()->getKey(m_db->publicUuid(), keyData)) { + m_ui->messageWidget->showMessage( + tr("Failed to authenticate with Quick Unlock: %1").arg(getQuickUnlock()->errorString()), + MessageWidget::Error); return {}; } -#endif databaseKey->setRawKey(keyData); return databaseKey; } @@ -553,10 +527,11 @@ void DatabaseOpenWidget::triggerQuickUnlock() */ void DatabaseOpenWidget::resetQuickUnlock() { -#if defined(Q_CC_MSVC) - getWindowsHello()->reset(m_filename); -#elif defined(Q_OS_MACOS) - TouchID::getInstance().reset(m_filename); -#endif + if (!isQuickUnlockAvailable()) { + return; + } + if (!m_db.isNull()) { + getQuickUnlock()->reset(m_db->publicUuid()); + } load(m_filename); } diff --git a/src/gui/DatabaseTabWidget.cpp b/src/gui/DatabaseTabWidget.cpp index d4e50116f8..4454266339 100644 --- a/src/gui/DatabaseTabWidget.cpp +++ b/src/gui/DatabaseTabWidget.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 @@ -41,6 +41,7 @@ DatabaseTabWidget::DatabaseTabWidget(QWidget* parent) , m_dbWidgetStateSync(new DatabaseWidgetStateSync(this)) , m_dbWidgetPendingLock(nullptr) , m_databaseOpenDialog(new DatabaseOpenDialog(this)) + , m_databaseOpenInProgress(false) { auto* tabBar = new QTabBar(this); tabBar->setAcceptDrops(true); @@ -555,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) { @@ -857,6 +870,7 @@ void DatabaseTabWidget::emitDatabaseLockChanged() emit databaseLocked(dbWidget); } else { emit databaseUnlocked(dbWidget); + m_databaseOpenInProgress = false; } } @@ -889,6 +903,11 @@ void DatabaseTabWidget::performGlobalAutoType(const QString& search) void DatabaseTabWidget::performBrowserUnlock() { + if (m_databaseOpenInProgress) { + return; + } + + m_databaseOpenInProgress = true; auto dbWidget = currentDatabaseWidget(); if (dbWidget && dbWidget->isLocked()) { unlockAnyDatabaseInDialog(DatabaseOpenDialog::Intent::Browser); diff --git a/src/gui/DatabaseTabWidget.h b/src/gui/DatabaseTabWidget.h index 3a6791a806..6b4b121af7 100644 --- a/src/gui/DatabaseTabWidget.h +++ b/src/gui/DatabaseTabWidget.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 @@ -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(); @@ -117,6 +122,7 @@ private slots: QPointer m_dbWidgetPendingLock; QPointer m_databaseOpenDialog; QTimer m_lockDelayTimer; + bool m_databaseOpenInProgress; }; #endif // KEEPASSX_DATABASETABWIDGET_H 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/IconDownloader.cpp b/src/gui/IconDownloader.cpp index 7e3fff0aec..1adb269229 100644 --- a/src/gui/IconDownloader.cpp +++ b/src/gui/IconDownloader.cpp @@ -1,5 +1,5 @@ /* - * Copyright (C) 2019 KeePassXC Team + * Copyright (C) 2023 KeePassXC Team * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,6 +18,7 @@ #include "IconDownloader.h" #include "core/Config.h" #include "core/NetworkManager.h" +#include "core/UrlTools.h" #include #include @@ -40,37 +41,6 @@ IconDownloader::~IconDownloader() abortDownload(); } -namespace -{ - // Try to get the 2nd level domain of the host part of a QUrl. For example, - // "foo.bar.example.com" would become "example.com", and "foo.bar.example.co.uk" - // would become "example.co.uk". - QString getSecondLevelDomain(const QUrl& url) - { - QString fqdn = url.host(); - fqdn.truncate(fqdn.length() - url.topLevelDomain().length()); - QStringList parts = fqdn.split('.'); - QString newdom = parts.takeLast() + url.topLevelDomain(); - return newdom; - } - - QUrl convertVariantToUrl(const QVariant& var) - { - QUrl url; - if (var.canConvert()) { - url = var.toUrl(); - } - return url; - } - - QUrl getRedirectTarget(QNetworkReply* reply) - { - QVariant var = reply->attribute(QNetworkRequest::RedirectionTargetAttribute); - QUrl url = convertVariantToUrl(var); - return url; - } -} // namespace - void IconDownloader::setUrl(const QString& entryUrl) { m_url = entryUrl; @@ -114,7 +84,7 @@ void IconDownloader::setUrl(const QString& entryUrl) // Determine the second-level domain, if available QString secondLevelDomain; if (!hostIsIp) { - secondLevelDomain = getSecondLevelDomain(url); + secondLevelDomain = urlTools()->getBaseDomainFromUrl(url.toString()); } // Start with the "fallback" url (if enabled) to try to get the best favicon @@ -202,7 +172,7 @@ void IconDownloader::fetchFinished() QString url = m_url; bool error = (m_reply->error() != QNetworkReply::NoError); - QUrl redirectTarget = getRedirectTarget(m_reply); + QUrl redirectTarget = urlTools()->getRedirectTarget(m_reply); m_reply->deleteLater(); m_reply = nullptr; diff --git a/src/gui/MainWindow.cpp b/src/gui/MainWindow.cpp index adff01e2b8..06f47b2310 100644 --- a/src/gui/MainWindow.cpp +++ b/src/gui/MainWindow.cpp @@ -445,6 +445,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())); @@ -490,6 +495,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())); @@ -1000,6 +1009,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(); @@ -1067,6 +1080,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/URLEdit.cpp b/src/gui/URLEdit.cpp index d249ddd850..f5fbbb24be 100644 --- a/src/gui/URLEdit.cpp +++ b/src/gui/URLEdit.cpp @@ -1,6 +1,6 @@ /* + * Copyright (C) 2023 KeePassXC Team * Copyright (C) 2014 Felix Geyer - * Copyright (C) 2020 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 @@ -19,6 +19,7 @@ #include "URLEdit.h" #include "core/Tools.h" +#include "core/UrlTools.h" #include "gui/Icons.h" #include "gui/styles/StateColorPalette.h" @@ -44,7 +45,7 @@ void URLEdit::updateStylesheet() { const QString stylesheetTemplate("QLineEdit { background: %1; }"); - if (!Tools::checkUrlValid(text())) { + if (!urlTools()->isUrlValid(text())) { StateColorPalette statePalette; QColor color = statePalette.color(StateColorPalette::ColorRole::Error); setStyleSheet(stylesheetTemplate.arg(color.name())); diff --git a/src/gui/dbsettings/DatabaseSettingsWidgetDatabaseKey.cpp b/src/gui/dbsettings/DatabaseSettingsWidgetDatabaseKey.cpp index 41b4eea6de..bac749979c 100644 --- a/src/gui/dbsettings/DatabaseSettingsWidgetDatabaseKey.cpp +++ b/src/gui/dbsettings/DatabaseSettingsWidgetDatabaseKey.cpp @@ -25,13 +25,7 @@ #include "keys/ChallengeResponseKey.h" #include "keys/FileKey.h" #include "keys/PasswordKey.h" - -#ifdef Q_OS_MACOS -#include "touchid/TouchID.h" -#endif -#ifdef Q_CC_MSVC -#include "winhello/WindowsHello.h" -#endif +#include "quickunlock/QuickUnlockInterface.h" #include #include @@ -198,11 +192,7 @@ bool DatabaseSettingsWidgetDatabaseKey::save() m_db->setKey(newKey, true, false, false); -#if defined(Q_OS_MACOS) - TouchID::getInstance().reset(m_db->filePath()); -#elif defined(Q_CC_MSVC) - getWindowsHello()->reset(m_db->filePath()); -#endif + getQuickUnlock()->reset(m_db->publicUuid()); emit editFinished(true); if (m_isDirty) { diff --git a/src/gui/entry/EntryURLModel.cpp b/src/gui/entry/EntryURLModel.cpp index 55d8dd51cd..9a4340f5cd 100644 --- a/src/gui/entry/EntryURLModel.cpp +++ b/src/gui/entry/EntryURLModel.cpp @@ -20,7 +20,7 @@ #include "browser/BrowserService.h" #include "core/EntryAttributes.h" -#include "core/Tools.h" +#include "core/UrlTools.h" #include "gui/Icons.h" #include "gui/styles/StateColorPalette.h" @@ -67,14 +67,14 @@ QVariant EntryURLModel::data(const QModelIndex& index, int role) const } const auto value = m_entryAttributes->value(key); - const auto urlValid = Tools::checkUrlValid(value); + const auto urlValid = urlTools()->isUrlValid(value); // Check for duplicate URLs in the attribute list. Excludes the current key/value from the comparison. auto customAttributeKeys = m_entryAttributes->customKeys().filter(BrowserService::ADDITIONAL_URL); customAttributeKeys.removeOne(key); - const auto duplicateUrl = m_entryAttributes->values(customAttributeKeys).contains(value) - || browserService()->isUrlIdentical(value, m_entryUrl); + const auto duplicateUrl = + m_entryAttributes->values(customAttributeKeys).contains(value) || urlTools()->isUrlIdentical(value, m_entryUrl); if (role == Qt::BackgroundRole && (!urlValid || duplicateUrl)) { StateColorPalette statePalette; return statePalette.color(StateColorPalette::ColorRole::Error); diff --git a/src/gui/entry/EntryView.cpp b/src/gui/entry/EntryView.cpp index c587f7c1ea..7c5768ef4b 100644 --- a/src/gui/entry/EntryView.cpp +++ b/src/gui/entry/EntryView.cpp @@ -559,6 +559,8 @@ void EntryView::startDrag(Qt::DropActions supportedActions) listWidget.addItem(item); } + listWidget.setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + listWidget.setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); listWidget.setStyleSheet("QListWidget { background-color: palette(highlight); border: 1px solid palette(dark); " "padding: 4px; color: palette(highlighted-text); }"); auto width = listWidget.sizeHintForColumn(0) + 2 * listWidget.frameWidth(); diff --git a/src/gui/osutils/nixutils/NixUtils.cpp b/src/gui/osutils/nixutils/NixUtils.cpp index ebbea91d36..194b62058e 100644 --- a/src/gui/osutils/nixutils/NixUtils.cpp +++ b/src/gui/osutils/nixutils/NixUtils.cpp @@ -21,6 +21,7 @@ #include #include +#include #include #include #include @@ -323,3 +324,29 @@ void NixUtils::setColorScheme(QDBusVariant value) m_systemColorschemePrefExists = true; emit interfaceThemeChanged(); } + +quint64 NixUtils::getProcessStartTime() const +{ + QString processStatPath = QString("/proc/%1/stat").arg(QCoreApplication::applicationPid()); + QFile processStatFile(processStatPath); + + if (!processStatFile.open(QIODevice::ReadOnly | QIODevice::Text)) { + qDebug() << "nixutils: failed to open " << processStatPath; + return 0; + } + + QTextStream processStatStream(&processStatFile); + QString processStatInfo = processStatStream.readLine(); + processStatFile.close(); + + auto startIndex = processStatInfo.indexOf(')', -1); + if (startIndex != -1) { + auto tokens = processStatInfo.midRef(startIndex + 2).split(' '); + if (tokens.size() >= 20) { + return tokens[19].toULongLong(); + } + } + + qDebug() << "nixutils: failed to parse " << processStatPath; + return 0; +} diff --git a/src/gui/osutils/nixutils/NixUtils.h b/src/gui/osutils/nixutils/NixUtils.h index e3a17b9509..04cd086287 100644 --- a/src/gui/osutils/nixutils/NixUtils.h +++ b/src/gui/osutils/nixutils/NixUtils.h @@ -49,6 +49,8 @@ class NixUtils : public OSUtilsBase, QAbstractNativeEventFilter return false; } + quint64 getProcessStartTime() const; + private slots: void handleColorSchemeRead(QDBusVariant value); void handleColorSchemeChanged(QString ns, QString key, QDBusVariant value); 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..36fa2e4499 --- /dev/null +++ b/src/gui/passkeys/PasskeyExporter.cpp @@ -0,0 +1,105 @@ +/* + * 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 +#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 in JSON: + * { + * "privateKey": , + * "relyingParty: , + * "url": , + * "userHandle": , + * "userId": , + * "username:" + * } + */ +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; + } + + 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/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..2d54ba5bad --- /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(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); + + 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("Database: %1").arg(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..103b1df4e7 --- /dev/null +++ b/src/gui/passkeys/PasskeyImporter.cpp @@ -0,0 +1,139 @@ +/* + * 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/BrowserMessageBuilder.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 + +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) +{ + 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, + 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 123e02c2cb..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,14 +25,15 @@ #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" #include "core/Global.h" #include "core/Group.h" -#ifdef Q_OS_MACOS -#include "touchid/TouchID.h" -#endif class ReportsDialog::ExtraPage { @@ -64,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)) { @@ -71,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); @@ -91,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))); } @@ -117,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); @@ -151,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/src/gui/styles/base/classicstyle.qss b/src/gui/styles/base/classicstyle.qss index f7d3c0fb47..d0ab2b88fc 100644 --- a/src/gui/styles/base/classicstyle.qss +++ b/src/gui/styles/base/classicstyle.qss @@ -9,8 +9,9 @@ QToolTip { DatabaseWidget #SearchBanner, DatabaseWidget #KeeShareBanner { font-weight: bold; - background-color: rgb(94, 161, 14); - border: 1px solid rgb(190, 190, 190); + background-color: palette(highlight); + color: palette(highlighted-text); + border: 1px solid palette(dark); padding: 2px; } diff --git a/src/quickunlock/Polkit.cpp b/src/quickunlock/Polkit.cpp new file mode 100644 index 0000000000..38b9380d6d --- /dev/null +++ b/src/quickunlock/Polkit.cpp @@ -0,0 +1,247 @@ +/* + * 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 "Polkit.h" + +#include "crypto/CryptoHash.h" +#include "crypto/Random.h" +#include "crypto/SymmetricCipher.h" +#include "gui/osutils/nixutils/NixUtils.h" + +#include +#include +#include +#include +#include + +extern "C" { +#include +} + +const QString polkit_service = "org.freedesktop.PolicyKit1"; +const QString polkit_object = "/org/freedesktop/PolicyKit1/Authority"; + +namespace +{ + QString getKeyName(const QUuid& dbUuid) + { + static const QString keyPrefix = "keepassxc_polkit_keys_"; + return keyPrefix + dbUuid.toString(); + } +} // namespace + +Polkit::Polkit() +{ + PolkitSubject::registerMetaType(); + PolkitAuthorizationResults::registerMetaType(); + + /* Note we explicitly use our own dbus path here, as the ::systemBus() method could be overriden + through an environment variable to return an alternative bus path. This bus could have an application + pretending to be polkit running on it, which could approve every authentication request + + Most Linux distros place the system bus at this exact path, so it is hard-coded. + For any other distros, this path will need to be patched before compilation. + */ + QDBusConnection bus = + QDBusConnection::connectToBus("unix:path=/run/dbus/system_bus_socket", "keepassxc_polkit_dbus"); + + m_available = bus.isConnected(); + if (!m_available) { + qDebug() << "polkit: Failed to connect to system dbus (this may be due to a non-standard dbus path)"; + return; + } + + m_available = bus.interface()->isServiceRegistered(polkit_service); + + if (!m_available) { + qDebug() << "polkit: Polkit is not registered on dbus"; + return; + } + + m_polkit.reset(new org::freedesktop::PolicyKit1::Authority(polkit_service, polkit_object, bus)); +} + +Polkit::~Polkit() +{ +} + +void Polkit::reset(const QUuid& dbUuid) +{ + m_encryptedMasterKeys.remove(dbUuid); +} + +bool Polkit::isAvailable() const +{ + return m_available; +} + +QString Polkit::errorString() const +{ + return m_error; +} + +void Polkit::reset() +{ + m_encryptedMasterKeys.clear(); +} + +bool Polkit::setKey(const QUuid& dbUuid, const QByteArray& key) +{ + reset(dbUuid); + + // Generate a random iv/key pair to encrypt the master password with + QByteArray randomKey = randomGen()->randomArray(SymmetricCipher::keySize(SymmetricCipher::Aes256_GCM)); + QByteArray randomIV = randomGen()->randomArray(SymmetricCipher::defaultIvSize(SymmetricCipher::Aes256_GCM)); + QByteArray keychainKeyValue = randomKey + randomIV; + + SymmetricCipher aes256Encrypt; + if (!aes256Encrypt.init(SymmetricCipher::Aes256_GCM, SymmetricCipher::Encrypt, randomKey, randomIV)) { + m_error = QObject::tr("AES initialization failed"); + return false; + } + + // Encrypt the master password + QByteArray encryptedMasterKey = key; + if (!aes256Encrypt.finish(encryptedMasterKey)) { + m_error = QObject::tr("AES encrypt failed"); + qDebug() << "polkit aes encrypt failed: " << aes256Encrypt.errorString(); + return false; + } + + // Add the iv/key pair into the linux keyring + key_serial_t key_serial = add_key("user", + getKeyName(dbUuid).toStdString().c_str(), + keychainKeyValue.constData(), + keychainKeyValue.size(), + KEY_SPEC_PROCESS_KEYRING); + if (key_serial < 0) { + m_error = QObject::tr("Failed to store in Linux Keyring"); + qDebug() << "polkit keyring failed to store: " << errno; + return false; + } + + // Scrub the keys from ram + Botan::secure_scrub_memory(randomKey.data(), randomKey.size()); + Botan::secure_scrub_memory(randomIV.data(), randomIV.size()); + Botan::secure_scrub_memory(keychainKeyValue.data(), keychainKeyValue.size()); + + // Store encrypted master password and return + m_encryptedMasterKeys.insert(dbUuid, encryptedMasterKey); + return true; +} + +bool Polkit::getKey(const QUuid& dbUuid, QByteArray& key) +{ + if (!m_polkit || !hasKey(dbUuid)) { + return false; + } + + PolkitSubject subject; + subject.kind = "unix-process"; + subject.details.insert("pid", static_cast(QCoreApplication::applicationPid())); + subject.details.insert("start-time", nixUtils()->getProcessStartTime()); + + QMap details; + + auto result = m_polkit->CheckAuthorization( + subject, + "org.keepassxc.KeePassXC.unlockDatabase", + details, + 0x00000001, + // AllowUserInteraction - wait for user to authenticate + // https://www.freedesktop.org/software/polkit/docs/0.105/eggdbus-interface-org.freedesktop.PolicyKit1.Authority.html#eggdbus-enum-CheckAuthorizationFlags + ""); + + // A general error occurred + if (result.isError()) { + auto msg = result.error().message(); + m_error = QObject::tr("Polkit returned an error: %1").arg(msg); + qDebug() << "polkit returned an error: " << msg; + return false; + } + + PolkitAuthorizationResults authResult = result.value(); + if (authResult.is_authorized) { + QByteArray encryptedMasterKey = m_encryptedMasterKeys.value(dbUuid); + key_serial_t keySerial = + find_key_by_type_and_desc("user", getKeyName(dbUuid).toStdString().c_str(), KEY_SPEC_PROCESS_KEYRING); + + if (keySerial == -1) { + m_error = QObject::tr("Could not locate key in keyring"); + qDebug() << "polkit keyring failed to find: " << errno; + return false; + } + + void* keychainBuffer; + long keychainDataSize = keyctl_read_alloc(keySerial, &keychainBuffer); + + if (keychainDataSize == -1) { + m_error = QObject::tr("Could not read key in keyring"); + qDebug() << "polkit keyring failed to read: " << errno; + return false; + } + + QByteArray keychainBytes(static_cast(keychainBuffer), keychainDataSize); + + Botan::secure_scrub_memory(keychainBuffer, keychainDataSize); + free(keychainBuffer); + + QByteArray keychainKey = keychainBytes.left(SymmetricCipher::keySize(SymmetricCipher::Aes256_GCM)); + QByteArray keychainIv = keychainBytes.right(SymmetricCipher::defaultIvSize(SymmetricCipher::Aes256_GCM)); + + SymmetricCipher aes256Decrypt; + if (!aes256Decrypt.init(SymmetricCipher::Aes256_GCM, SymmetricCipher::Decrypt, keychainKey, keychainIv)) { + m_error = QObject::tr("AES initialization failed"); + qDebug() << "polkit aes init failed"; + return false; + } + + key = encryptedMasterKey; + if (!aes256Decrypt.finish(key)) { + key.clear(); + m_error = QObject::tr("AES decrypt failed"); + qDebug() << "polkit aes decrypt failed: " << aes256Decrypt.errorString(); + return false; + } + + // Scrub the keys from ram + Botan::secure_scrub_memory(keychainKey.data(), keychainKey.size()); + Botan::secure_scrub_memory(keychainIv.data(), keychainIv.size()); + Botan::secure_scrub_memory(keychainBytes.data(), keychainBytes.size()); + Botan::secure_scrub_memory(encryptedMasterKey.data(), encryptedMasterKey.size()); + + return true; + } + + // Failed to authenticate + if (authResult.is_challenge) { + m_error = QObject::tr("No Polkit authentication agent was available"); + } else { + m_error = QObject::tr("Polkit authorization failed"); + } + + return false; +} + +bool Polkit::hasKey(const QUuid& dbUuid) const +{ + if (!m_encryptedMasterKeys.contains(dbUuid)) { + return false; + } + + return find_key_by_type_and_desc("user", getKeyName(dbUuid).toStdString().c_str(), KEY_SPEC_PROCESS_KEYRING) != -1; +} diff --git a/src/quickunlock/Polkit.h b/src/quickunlock/Polkit.h new file mode 100644 index 0000000000..7dfc2db7b1 --- /dev/null +++ b/src/quickunlock/Polkit.h @@ -0,0 +1,50 @@ +/* + * 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 KEEPASSX_POLKIT_H +#define KEEPASSX_POLKIT_H + +#include "QuickUnlockInterface.h" +#include "polkit_dbus.h" +#include +#include + +class Polkit : public QuickUnlockInterface +{ +public: + Polkit(); + ~Polkit() override; + + bool isAvailable() const override; + QString errorString() const override; + + bool setKey(const QUuid& dbUuid, const QByteArray& key) override; + bool getKey(const QUuid& dbUuid, QByteArray& key) override; + bool hasKey(const QUuid& dbUuid) const override; + + void reset(const QUuid& dbUuid) override; + void reset() override; + +private: + bool m_available; + QString m_error; + QHash m_encryptedMasterKeys; + + QScopedPointer m_polkit; +}; + +#endif // KEEPASSX_POLKIT_H diff --git a/src/quickunlock/PolkitDbusTypes.cpp b/src/quickunlock/PolkitDbusTypes.cpp new file mode 100644 index 0000000000..a4305dc445 --- /dev/null +++ b/src/quickunlock/PolkitDbusTypes.cpp @@ -0,0 +1,45 @@ +#include "PolkitDbusTypes.h" + +void PolkitSubject::registerMetaType() +{ + qRegisterMetaType("PolkitSubject"); + qDBusRegisterMetaType(); +} + +QDBusArgument& operator<<(QDBusArgument& argument, const PolkitSubject& subject) +{ + argument.beginStructure(); + argument << subject.kind << subject.details; + argument.endStructure(); + return argument; +} + +const QDBusArgument& operator>>(const QDBusArgument& argument, PolkitSubject& subject) +{ + argument.beginStructure(); + argument >> subject.kind >> subject.details; + argument.endStructure(); + return argument; +} + +void PolkitAuthorizationResults::registerMetaType() +{ + qRegisterMetaType("PolkitAuthorizationResults"); + qDBusRegisterMetaType(); +} + +QDBusArgument& operator<<(QDBusArgument& argument, const PolkitAuthorizationResults& res) +{ + argument.beginStructure(); + argument << res.is_authorized << res.is_challenge << res.details; + argument.endStructure(); + return argument; +} + +const QDBusArgument& operator>>(const QDBusArgument& argument, PolkitAuthorizationResults& res) +{ + argument.beginStructure(); + argument >> res.is_authorized >> res.is_challenge >> res.details; + argument.endStructure(); + return argument; +} diff --git a/src/quickunlock/PolkitDbusTypes.h b/src/quickunlock/PolkitDbusTypes.h new file mode 100644 index 0000000000..83eb238893 --- /dev/null +++ b/src/quickunlock/PolkitDbusTypes.h @@ -0,0 +1,36 @@ +#ifndef KEEPASSX_POLKITDBUSTYPES_H +#define KEEPASSX_POLKITDBUSTYPES_H + +#include + +class PolkitSubject +{ +public: + QString kind; + QVariantMap details; + + static void registerMetaType(); + + friend QDBusArgument& operator<<(QDBusArgument& argument, const PolkitSubject& subject); + + friend const QDBusArgument& operator>>(const QDBusArgument& argument, PolkitSubject& subject); +}; + +class PolkitAuthorizationResults +{ +public: + bool is_authorized; + bool is_challenge; + QMap details; + + static void registerMetaType(); + + friend QDBusArgument& operator<<(QDBusArgument& argument, const PolkitAuthorizationResults& subject); + + friend const QDBusArgument& operator>>(const QDBusArgument& argument, PolkitAuthorizationResults& subject); +}; + +Q_DECLARE_METATYPE(PolkitSubject); +Q_DECLARE_METATYPE(PolkitAuthorizationResults); + +#endif // KEEPASSX_POLKITDBUSTYPES_H diff --git a/src/quickunlock/QuickUnlockInterface.cpp b/src/quickunlock/QuickUnlockInterface.cpp new file mode 100644 index 0000000000..0e24736e80 --- /dev/null +++ b/src/quickunlock/QuickUnlockInterface.cpp @@ -0,0 +1,81 @@ +/* + * 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 "QuickUnlockInterface.h" +#include + +#if defined(Q_OS_MACOS) +#include "TouchID.h" +#define QUICKUNLOCK_IMPLEMENTATION TouchID +#elif defined(Q_CC_MSVC) +#include "WindowsHello.h" +#define QUICKUNLOCK_IMPLEMENTATION WindowsHello +#elif defined(Q_OS_LINUX) +#include "Polkit.h" +#define QUICKUNLOCK_IMPLEMENTATION Polkit +#else +#define QUICKUNLOCK_IMPLEMENTATION NoQuickUnlock +#endif + +QUICKUNLOCK_IMPLEMENTATION* quickUnlockInstance = {nullptr}; + +QuickUnlockInterface* getQuickUnlock() +{ + if (!quickUnlockInstance) { + quickUnlockInstance = new QUICKUNLOCK_IMPLEMENTATION(); + } + return quickUnlockInstance; +} + +bool NoQuickUnlock::isAvailable() const +{ + return false; +} + +QString NoQuickUnlock::errorString() const +{ + return QObject::tr("No Quick Unlock provider is available"); +} + +void NoQuickUnlock::reset() +{ +} + +bool NoQuickUnlock::setKey(const QUuid& dbUuid, const QByteArray& key) +{ + Q_UNUSED(dbUuid) + Q_UNUSED(key) + return false; +} + +bool NoQuickUnlock::getKey(const QUuid& dbUuid, QByteArray& key) +{ + Q_UNUSED(dbUuid) + Q_UNUSED(key) + return false; +} + +bool NoQuickUnlock::hasKey(const QUuid& dbUuid) const +{ + Q_UNUSED(dbUuid) + return false; +} + +void NoQuickUnlock::reset(const QUuid& dbUuid) +{ + Q_UNUSED(dbUuid) +} diff --git a/src/quickunlock/QuickUnlockInterface.h b/src/quickunlock/QuickUnlockInterface.h new file mode 100644 index 0000000000..54aeb8a627 --- /dev/null +++ b/src/quickunlock/QuickUnlockInterface.h @@ -0,0 +1,58 @@ +/* + * 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_QUICKUNLOCKINTERFACE_H +#define KEEPASSXC_QUICKUNLOCKINTERFACE_H + +#include + +class QuickUnlockInterface +{ + Q_DISABLE_COPY(QuickUnlockInterface) + +public: + QuickUnlockInterface() = default; + virtual ~QuickUnlockInterface() = default; + + virtual bool isAvailable() const = 0; + virtual QString errorString() const = 0; + + virtual bool setKey(const QUuid& dbUuid, const QByteArray& key) = 0; + virtual bool getKey(const QUuid& dbUuid, QByteArray& key) = 0; + virtual bool hasKey(const QUuid& dbUuid) const = 0; + + virtual void reset(const QUuid& dbUuid) = 0; + virtual void reset() = 0; +}; + +class NoQuickUnlock : public QuickUnlockInterface +{ +public: + bool isAvailable() const override; + QString errorString() const override; + + bool setKey(const QUuid& dbUuid, const QByteArray& key) override; + bool getKey(const QUuid& dbUuid, QByteArray& key) override; + bool hasKey(const QUuid& dbUuid) const override; + + void reset(const QUuid& dbUuid) override; + void reset() override; +}; + +QuickUnlockInterface* getQuickUnlock(); + +#endif // KEEPASSXC_QUICKUNLOCKINTERFACE_H diff --git a/src/quickunlock/TouchID.h b/src/quickunlock/TouchID.h new file mode 100644 index 0000000000..2cca7ea464 --- /dev/null +++ b/src/quickunlock/TouchID.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 KEEPASSX_TOUCHID_H +#define KEEPASSX_TOUCHID_H + +#include "QuickUnlockInterface.h" +#include + +class TouchID : public QuickUnlockInterface +{ +public: + bool isAvailable() const override; + QString errorString() const override; + + bool setKey(const QUuid& dbUuid, const QByteArray& passwordKey) override; + bool getKey(const QUuid& dbUuid, QByteArray& passwordKey) override; + bool hasKey(const QUuid& dbUuid) const override; + + void reset(const QUuid& dbUuid = "") override; + void reset() override; + +private: + static bool isWatchAvailable(); + static bool isTouchIdAvailable(); + + static void deleteKeyEntry(const QString& accountName); + static QString databaseKeyName(const QUuid& dbUuid); + + QHash m_encryptedMasterKeys; +}; + +#endif // KEEPASSX_TOUCHID_H diff --git a/src/touchid/TouchID.mm b/src/quickunlock/TouchID.mm similarity index 85% rename from src/touchid/TouchID.mm rename to src/quickunlock/TouchID.mm index cc858a89a8..502a508c4b 100644 --- a/src/touchid/TouchID.mm +++ b/src/quickunlock/TouchID.mm @@ -1,4 +1,4 @@ -#include "touchid/TouchID.h" +#include "quickunlock/TouchID.h" #include "crypto/Random.h" #include "crypto/SymmetricCipher.h" @@ -13,6 +13,7 @@ #include #include +#include #define TOUCH_ID_ENABLE_DEBUG_LOGS() 0 #if TOUCH_ID_ENABLE_DEBUG_LOGS() @@ -54,16 +55,6 @@ inline CFMutableDictionaryRef makeDictionary() { return CFDictionaryCreateMutable(NULL, 0, &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks); } -/** - * Singleton - */ -TouchID& TouchID::getInstance() -{ - static TouchID instance; // Guaranteed to be destroyed. - // Instantiated on first use. - return instance; -} - //! Try to delete an existing keychain entry void TouchID::deleteKeyEntry(const QString& accountName) { @@ -77,14 +68,24 @@ inline CFMutableDictionaryRef makeDictionary() { // get data from the KeyChain OSStatus status = SecItemDelete(query); - LogStatusError("TouchID::storeKey - Status deleting existing entry", status); + LogStatusError("TouchID::deleteKeyEntry - Status deleting existing entry", status); } -QString TouchID::databaseKeyName(const QString &databasePath) +QString TouchID::databaseKeyName(const QUuid& dbUuid) { static const QString keyPrefix = "KeepassXC_TouchID_Keys_"; - const QByteArray pathHash = CryptoHash::hash(databasePath.toUtf8(), CryptoHash::Sha256).toHex(); - return keyPrefix + pathHash; + return keyPrefix + dbUuid.toString(); +} + +QString TouchID::errorString() const +{ + // TODO + return ""; +} + +void TouchID::reset() +{ + m_encryptedMasterKeys.clear(); } /** @@ -92,15 +93,15 @@ inline CFMutableDictionaryRef makeDictionary() { * protects the database. The encrypted PasswordKey is kept in memory while the * AES key is stored in the macOS KeyChain protected by either TouchID or Apple Watch. */ -bool TouchID::storeKey(const QString& databasePath, const QByteArray& passwordKey) +bool TouchID::setKey(const QUuid& dbUuid, const QByteArray& passwordKey) { - if (databasePath.isEmpty() || passwordKey.isEmpty()) { - debug("TouchID::storeKey - illegal arguments"); + if (passwordKey.isEmpty()) { + debug("TouchID::setKey - illegal arguments"); return false; } - if (m_encryptedMasterKeys.contains(databasePath)) { - debug("TouchID::storeKey - Already stored key for this database"); + if (m_encryptedMasterKeys.contains(dbUuid)) { + debug("TouchID::setKey - Already stored key for this database"); return true; } @@ -110,7 +111,7 @@ inline CFMutableDictionaryRef makeDictionary() { SymmetricCipher aes256Encrypt; if (!aes256Encrypt.init(SymmetricCipher::Aes256_GCM, SymmetricCipher::Encrypt, randomKey, randomIV)) { - debug("TouchID::storeKey - AES initialisation failed"); + debug("TouchID::setKey - AES initialisation failed"); return false; } @@ -121,7 +122,7 @@ inline CFMutableDictionaryRef makeDictionary() { return false; } - const QString keyName = databaseKeyName(databasePath); + const QString keyName = databaseKeyName(dbUuid); deleteKeyEntry(keyName); // Try to delete the existing key entry @@ -152,7 +153,7 @@ inline CFMutableDictionaryRef makeDictionary() { if (sacObject == NULL || error != NULL) { NSError* e = (__bridge NSError*) error; - debug("TouchID::storeKey - Error creating security flags: %s", e.localizedDescription.UTF8String); + debug("TouchID::setKey - Error creating security flags: %s", e.localizedDescription.UTF8String); return false; } @@ -174,7 +175,7 @@ inline CFMutableDictionaryRef makeDictionary() { // add to KeyChain OSStatus status = SecItemAdd(attributes, NULL); - LogStatusError("TouchID::storeKey - Status adding new entry", status); + LogStatusError("TouchID::setKey - Status adding new entry", status); CFRelease(sacObject); CFRelease(attributes); @@ -188,8 +189,8 @@ inline CFMutableDictionaryRef makeDictionary() { Botan::secure_scrub_memory(randomIV.data(), randomIV.size()); // memorize which database the stored key is for - m_encryptedMasterKeys.insert(databasePath, encryptedMasterKey); - debug("TouchID::storeKey - Success!"); + m_encryptedMasterKeys.insert(dbUuid, encryptedMasterKey); + debug("TouchID::setKey - Success!"); return true; } @@ -197,15 +198,11 @@ inline CFMutableDictionaryRef makeDictionary() { * Checks if an encrypted PasswordKey is available for the given database, tries to * decrypt it using the KeyChain and if successful, returns it. */ -bool TouchID::getKey(const QString& databasePath, QByteArray& passwordKey) const +bool TouchID::getKey(const QUuid& dbUuid, QByteArray& passwordKey) { passwordKey.clear(); - if (databasePath.isEmpty()) { - debug("TouchID::getKey - missing database path"); - return false; - } - if (!containsKey(databasePath)) { + if (!hasKey(dbUuid)) { debug("TouchID::getKey - No stored key found"); return false; } @@ -213,7 +210,7 @@ inline CFMutableDictionaryRef makeDictionary() { // query the KeyChain for the AES key CFMutableDictionaryRef query = makeDictionary(); - const QString keyName = databaseKeyName(databasePath); + const QString keyName = databaseKeyName(dbUuid); NSString* accountName = keyName.toNSString(); // The NSString is released by Qt NSString* touchPromptMessage = QCoreApplication::translate("DatabaseOpenWidget", "authenticate to access the database") @@ -254,7 +251,7 @@ inline CFMutableDictionaryRef makeDictionary() { } // decrypt PasswordKey from memory using AES - passwordKey = m_encryptedMasterKeys[databasePath]; + passwordKey = m_encryptedMasterKeys[dbUuid]; if (!aes256Decrypt.finish(passwordKey)) { passwordKey.clear(); debug("TouchID::getKey - AES decrypt failed: %s", aes256Decrypt.errorString().toUtf8().constData()); @@ -268,9 +265,9 @@ inline CFMutableDictionaryRef makeDictionary() { return true; } -bool TouchID::containsKey(const QString& dbPath) const +bool TouchID::hasKey(const QUuid& dbUuid) const { - return m_encryptedMasterKeys.contains(dbPath); + return m_encryptedMasterKeys.contains(dbUuid); } // TODO: Both functions below should probably handle the returned errors to @@ -336,7 +333,7 @@ inline CFMutableDictionaryRef makeDictionary() { } //! @return true if either TouchID or Apple Watch is available at the moment. -bool TouchID::isAvailable() +bool TouchID::isAvailable() const { // note: we cannot cache the check results because the configuration // is dynamic in its nature. User can close the laptop lid or take off @@ -349,12 +346,7 @@ inline CFMutableDictionaryRef makeDictionary() { /** * Resets the inner state either for all or for the given database */ -void TouchID::reset(const QString& databasePath) +void TouchID::reset(const QUuid& dbUuid) { - if (databasePath.isEmpty()) { - m_encryptedMasterKeys.clear(); - return; - } - - m_encryptedMasterKeys.remove(databasePath); + m_encryptedMasterKeys.remove(dbUuid); } diff --git a/src/winhello/WindowsHello.cpp b/src/quickunlock/WindowsHello.cpp similarity index 82% rename from src/winhello/WindowsHello.cpp rename to src/quickunlock/WindowsHello.cpp index bc244cc26c..890e3499a5 100644 --- a/src/winhello/WindowsHello.cpp +++ b/src/quickunlock/WindowsHello.cpp @@ -99,28 +99,10 @@ namespace } } // namespace -WindowsHello* WindowsHello::m_instance{nullptr}; -WindowsHello* WindowsHello::instance() -{ - if (!m_instance) { - m_instance = new WindowsHello(); - } - return m_instance; -} - -WindowsHello::WindowsHello(QObject* parent) - : QObject(parent) -{ - concurrency::create_task([this] { - bool state = KeyCredentialManager::IsSupportedAsync().get(); - m_available = state; - emit availableChanged(m_available); - }); -} - bool WindowsHello::isAvailable() const { - return m_available; + auto task = concurrency::create_task([] { return KeyCredentialManager::IsSupportedAsync().get(); }); + return task.get(); } QString WindowsHello::errorString() const @@ -128,7 +110,7 @@ QString WindowsHello::errorString() const return m_error; } -bool WindowsHello::storeKey(const QString& dbPath, const QByteArray& data) +bool WindowsHello::setKey(const QUuid& dbUuid, const QByteArray& data) { queueSecurityPromptFocus(); @@ -144,26 +126,26 @@ bool WindowsHello::storeKey(const QString& dbPath, const QByteArray& data) // Encrypt the data using AES-256-CBC SymmetricCipher cipher; if (!cipher.init(SymmetricCipher::Aes256_GCM, SymmetricCipher::Encrypt, key, challenge)) { - m_error = tr("Failed to init KeePassXC crypto."); + m_error = QObject::tr("Failed to init KeePassXC crypto."); return false; } QByteArray encrypted = data; if (!cipher.finish(encrypted)) { - m_error = tr("Failed to encrypt key data."); + m_error = QObject::tr("Failed to encrypt key data."); return false; } // Prepend the challenge/IV to the encrypted data encrypted.prepend(challenge); - m_encryptedKeys.insert(dbPath, encrypted); + m_encryptedKeys.insert(dbUuid, encrypted); return true; } -bool WindowsHello::getKey(const QString& dbPath, QByteArray& data) +bool WindowsHello::getKey(const QUuid& dbUuid, QByteArray& data) { data.clear(); - if (!hasKey(dbPath)) { - m_error = tr("Failed to get Windows Hello credential."); + if (!hasKey(dbUuid)) { + m_error = QObject::tr("Failed to get Windows Hello credential."); return false; } @@ -171,7 +153,7 @@ bool WindowsHello::getKey(const QString& dbPath, QByteArray& data) // Read the previously used challenge and encrypted data auto ivSize = SymmetricCipher::defaultIvSize(SymmetricCipher::Aes256_GCM); - const auto& keydata = m_encryptedKeys.value(dbPath); + const auto& keydata = m_encryptedKeys.value(dbUuid); auto challenge = keydata.left(ivSize); auto encrypted = keydata.mid(ivSize); QByteArray key; @@ -183,7 +165,7 @@ bool WindowsHello::getKey(const QString& dbPath, QByteArray& data) // Decrypt the data using the generated key and IV from above SymmetricCipher cipher; if (!cipher.init(SymmetricCipher::Aes256_GCM, SymmetricCipher::Decrypt, key, challenge)) { - m_error = tr("Failed to init KeePassXC crypto."); + m_error = QObject::tr("Failed to init KeePassXC crypto."); return false; } @@ -191,21 +173,21 @@ bool WindowsHello::getKey(const QString& dbPath, QByteArray& data) data = encrypted; if (!cipher.finish(data)) { data.clear(); - m_error = tr("Failed to decrypt key data."); + m_error = QObject::tr("Failed to decrypt key data."); return false; } return true; } -void WindowsHello::reset(const QString& dbPath) +void WindowsHello::reset(const QUuid& dbUuid) { - m_encryptedKeys.remove(dbPath); + m_encryptedKeys.remove(dbUuid); } -bool WindowsHello::hasKey(const QString& dbPath) const +bool WindowsHello::hasKey(const QUuid& dbUuid) const { - return m_encryptedKeys.contains(dbPath); + return m_encryptedKeys.contains(dbUuid); } void WindowsHello::reset() diff --git a/src/winhello/WindowsHello.h b/src/quickunlock/WindowsHello.h similarity index 57% rename from src/winhello/WindowsHello.h rename to src/quickunlock/WindowsHello.h index 5faf7eb256..ea59f91c30 100644 --- a/src/winhello/WindowsHello.h +++ b/src/quickunlock/WindowsHello.h @@ -18,41 +18,28 @@ #ifndef KEEPASSXC_WINDOWSHELLO_H #define KEEPASSXC_WINDOWSHELLO_H +#include "QuickUnlockInterface.h"; + #include #include -class WindowsHello : public QObject +class WindowsHello : public QuickUnlockInterface { - Q_OBJECT - public: - static WindowsHello* instance(); - bool isAvailable() const; - QString errorString() const; - void reset(); - - bool storeKey(const QString& dbPath, const QByteArray& key); - bool getKey(const QString& dbPath, QByteArray& key); - bool hasKey(const QString& dbPath) const; - void reset(const QString& dbPath); + WindowsHello() = default; + bool isAvailable() const override; + QString errorString() const override; + void reset() override; -signals: - void availableChanged(bool state); + bool setKey(const QUuid& dbUuid, const QByteArray& key) override; + bool getKey(const QUuid& dbUuid, QByteArray& key) override; + bool hasKey(const QUuid& dbUuid) const override; + void reset(const QUuid& dbUuid) override; private: - bool m_available = false; QString m_error; - QHash m_encryptedKeys; - - static WindowsHello* m_instance; - WindowsHello(QObject* parent = nullptr); - ~WindowsHello() override = default; + QHash m_encryptedKeys; Q_DISABLE_COPY(WindowsHello); }; -inline WindowsHello* getWindowsHello() -{ - return WindowsHello::instance(); -} - #endif // KEEPASSXC_WINDOWSHELLO_H diff --git a/src/quickunlock/dbus/org.freedesktop.PolicyKit1.Authority.xml b/src/quickunlock/dbus/org.freedesktop.PolicyKit1.Authority.xml new file mode 100644 index 0000000000..d46d71d2a0 --- /dev/null +++ b/src/quickunlock/dbus/org.freedesktop.PolicyKit1.Authority.xml @@ -0,0 +1,16 @@ + + + + + + + + + > + + + + + + + diff --git a/src/touchid/TouchID.h b/src/touchid/TouchID.h deleted file mode 100644 index e32f1fa126..0000000000 --- a/src/touchid/TouchID.h +++ /dev/null @@ -1,39 +0,0 @@ -#ifndef KEEPASSX_TOUCHID_H -#define KEEPASSX_TOUCHID_H - -#include - -class TouchID -{ -public: - static TouchID& getInstance(); - -private: - TouchID() - { - // Nothing to do here - } - -public: - TouchID(TouchID const&) = delete; - void operator=(TouchID const&) = delete; - - bool storeKey(const QString& databasePath, const QByteArray& passwordKey); - bool getKey(const QString& databasePath, QByteArray& passwordKey) const; - bool containsKey(const QString& databasePath) const; - void reset(const QString& databasePath = ""); - - bool isAvailable(); - -private: - static bool isWatchAvailable(); - static bool isTouchIdAvailable(); - - static void deleteKeyEntry(const QString& accountName); - static QString databaseKeyName(const QString& databasePath); - -private: - QHash m_encryptedMasterKeys; -}; - -#endif // KEEPASSX_TOUCHID_H diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index db82da1639..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 @@ -150,6 +150,8 @@ if(WITH_XC_NETWORKING) LIBS ${TEST_LIBRARIES}) add_unit_test(NAME testicondownloader SOURCES TestIconDownloader.cpp LIBS ${TEST_LIBRARIES}) + + add_unit_test(NAME testurltools SOURCES TestUrlTools.cpp LIBS ${TEST_LIBRARIES}) endif() if(WITH_XC_AUTOTYPE) @@ -231,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/TestBrowser.cpp b/tests/TestBrowser.cpp index d7345537d6..aa084921ed 100644 --- a/tests/TestBrowser.cpp +++ b/tests/TestBrowser.cpp @@ -144,54 +144,6 @@ void TestBrowser::testBuildResponse() QCOMPARE(firstArr["test"].toBool(), true); } -/** - * Tests for BrowserService - */ -void TestBrowser::testTopLevelDomain() -{ - QString url1 = "https://another.example.co.uk"; - QString url2 = "https://www.example.com"; - QString url3 = "http://test.net"; - QString url4 = "http://so.many.subdomains.co.jp"; - QString url5 = "https://192.168.0.1"; - QString url6 = "https://192.168.0.1:8000"; - - QString res1 = m_browserService->getTopLevelDomainFromUrl(url1); - QString res2 = m_browserService->getTopLevelDomainFromUrl(url2); - QString res3 = m_browserService->getTopLevelDomainFromUrl(url3); - QString res4 = m_browserService->getTopLevelDomainFromUrl(url4); - QString res5 = m_browserService->getTopLevelDomainFromUrl(url5); - QString res6 = m_browserService->getTopLevelDomainFromUrl(url6); - - QCOMPARE(res1, QString("example.co.uk")); - QCOMPARE(res2, QString("example.com")); - QCOMPARE(res3, QString("test.net")); - QCOMPARE(res4, QString("subdomains.co.jp")); - QCOMPARE(res5, QString("192.168.0.1")); - QCOMPARE(res6, QString("192.168.0.1")); -} - -void TestBrowser::testIsIpAddress() -{ - auto host1 = "example.com"; // Not valid - auto host2 = "192.168.0.1"; - auto host3 = "278.21.2.0"; // Not valid - auto host4 = "2001:0db8:85a3:0000:0000:8a2e:0370:7334"; - auto host5 = "2001:db8:0:1:1:1:1:1"; - auto host6 = "fe80::1ff:fe23:4567:890a"; - auto host7 = "2001:20::1"; - auto host8 = "2001:0db8:85y3:0000:0000:8a2e:0370:7334"; // Not valid - - QVERIFY(!m_browserService->isIpAddress(host1)); - QVERIFY(m_browserService->isIpAddress(host2)); - QVERIFY(!m_browserService->isIpAddress(host3)); - QVERIFY(m_browserService->isIpAddress(host4)); - QVERIFY(m_browserService->isIpAddress(host5)); - QVERIFY(m_browserService->isIpAddress(host6)); - QVERIFY(m_browserService->isIpAddress(host7)); - QVERIFY(!m_browserService->isIpAddress(host8)); -} - void TestBrowser::testSortPriority() { QFETCH(QString, entryUrl); @@ -583,26 +535,6 @@ QList TestBrowser::createEntries(QStringList& urls, Group* root) const return entries; } -void TestBrowser::testValidURLs() -{ - QHash urls; - urls["https://github.com/login"] = true; - urls["https:///github.com/"] = false; - urls["http://github.com/**//*"] = false; - urls["http://*.github.com/login"] = false; - urls["//github.com"] = true; - urls["github.com/{}<>"] = false; - urls["http:/example.com"] = false; - urls["cmd://C:/Toolchains/msys2/usr/bin/mintty \"ssh jon@192.168.0.1:22\""] = true; - urls["file:///Users/testUser/Code/test.html"] = true; - urls["{REF:A@I:46C9B1FFBD4ABC4BBB260C6190BAD20C} "] = true; - - QHashIterator i(urls); - while (i.hasNext()) { - i.next(); - QCOMPARE(Tools::checkUrlValid(i.key()), i.value()); - } -} void TestBrowser::testBestMatchingCredentials() { @@ -741,19 +673,3 @@ void TestBrowser::testBestMatchingWithAdditionalURLs() QCOMPARE(sorted.length(), 1); QCOMPARE(sorted[0]->url(), urls[0]); } - -void TestBrowser::testIsUrlIdentical() -{ - QVERIFY(browserService()->isUrlIdentical("https://example.com", "https://example.com")); - QVERIFY(browserService()->isUrlIdentical("https://example.com", " https://example.com ")); - QVERIFY(!browserService()->isUrlIdentical("https://example.com", "https://example2.com")); - QVERIFY(!browserService()->isUrlIdentical("https://example.com/", "https://example.com/#login")); - QVERIFY(browserService()->isUrlIdentical("https://example.com", "https://example.com/")); - QVERIFY(browserService()->isUrlIdentical("https://example.com/", "https://example.com")); - QVERIFY(browserService()->isUrlIdentical("https://example.com/ ", " https://example.com")); - QVERIFY(!browserService()->isUrlIdentical("https://example.com/", " example.com")); - QVERIFY(browserService()->isUrlIdentical("https://example.com/path/to/nowhere", - "https://example.com/path/to/nowhere/")); - QVERIFY(!browserService()->isUrlIdentical("https://example.com/", "://example.com/")); - QVERIFY(browserService()->isUrlIdentical("ftp://127.0.0.1/", "ftp://127.0.0.1")); -} diff --git a/tests/TestBrowser.h b/tests/TestBrowser.h index ed8146b574..48ac3b1cd5 100644 --- a/tests/TestBrowser.h +++ b/tests/TestBrowser.h @@ -37,8 +37,6 @@ private slots: void testGetBase64FromKey(); void testIncrementNonce(); void testBuildResponse(); - void testTopLevelDomain(); - void testIsIpAddress(); void testSortPriority(); void testSortPriority_data(); void testSearchEntries(); @@ -49,10 +47,8 @@ private slots: void testSearchEntriesWithAdditionalURLs(); void testInvalidEntries(); void testSubdomainsAndPaths(); - void testValidURLs(); void testBestMatchingCredentials(); void testBestMatchingWithAdditionalURLs(); - void testIsUrlIdentical(); private: QList createEntries(QStringList& urls, Group* root) const; diff --git a/tests/TestPasskeys.cpp b/tests/TestPasskeys.cpp new file mode 100644 index 0000000000..556e287d70 --- /dev/null +++ b/tests/TestPasskeys.cpp @@ -0,0 +1,471 @@ +/* + * 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 = 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 = 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 = 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() +{ + 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 diff --git a/tests/TestUrlTools.cpp b/tests/TestUrlTools.cpp new file mode 100644 index 0000000000..0e3ef844ee --- /dev/null +++ b/tests/TestUrlTools.cpp @@ -0,0 +1,129 @@ +/* + * 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 "TestUrlTools.h" +#include + +QTEST_GUILESS_MAIN(TestUrlTools) + +void TestUrlTools::initTestCase() +{ + m_urlTools = urlTools(); +} + +void TestUrlTools::init() +{ +} + +void TestUrlTools::testTopLevelDomain() +{ + // Create list of URLs and expected TLD responses + QList> tldUrls{ + {QString("https://another.example.co.uk"), QString("co.uk")}, + {QString("https://www.example.com"), QString("com")}, + {QString("https://github.com"), QString("com")}, + {QString("http://test.net"), QString("net")}, + {QString("http://so.many.subdomains.co.jp"), QString("co.jp")}, + {QString("https://192.168.0.1"), QString("192.168.0.1")}, + {QString("https://192.168.0.1:8000"), QString("192.168.0.1")}, + {QString("https://www.nic.ar"), QString("ar")}, + {QString("https://no.no.no"), QString("no")}, + {QString("https://www.blogspot.com.ar"), QString("blogspot.com.ar")}, // blogspot.com.ar is a TLD + {QString("https://jap.an.ide.kyoto.jp"), QString("ide.kyoto.jp")}, // ide.kyoto.jp is a TLD + {QString("ar"), QString("ar")}, + }; + + for (const auto& u : tldUrls) { + QCOMPARE(urlTools()->getTopLevelDomainFromUrl(u.first), u.second); + } + + // Create list of URLs and expected base URL responses + QList> baseUrls{ + {QString("https://another.example.co.uk"), QString("example.co.uk")}, + {QString("https://www.example.com"), QString("example.com")}, + {QString("http://test.net"), QString("test.net")}, + {QString("http://so.many.subdomains.co.jp"), QString("subdomains.co.jp")}, + {QString("https://192.168.0.1"), QString("192.168.0.1")}, + {QString("https://192.168.0.1:8000"), QString("192.168.0.1")}, + {QString("https://www.nic.ar"), QString("nic.ar")}, + {QString("https://www.blogspot.com.ar"), QString("www.blogspot.com.ar")}, // blogspot.com.ar is a TLD + {QString("https://www.arpa"), QString("www.arpa")}, + {QString("https://jap.an.ide.kyoto.jp"), QString("an.ide.kyoto.jp")}, // ide.kyoto.jp is a TLD + {QString("https://kobe.jp"), QString("kobe.jp")}, + }; + + for (const auto& u : baseUrls) { + QCOMPARE(urlTools()->getBaseDomainFromUrl(u.first), u.second); + } +} + +void TestUrlTools::testIsIpAddress() +{ + auto host1 = "example.com"; // Not valid + auto host2 = "192.168.0.1"; + auto host3 = "278.21.2.0"; // Not valid + auto host4 = "2001:0db8:85a3:0000:0000:8a2e:0370:7334"; + auto host5 = "2001:db8:0:1:1:1:1:1"; + auto host6 = "fe80::1ff:fe23:4567:890a"; + auto host7 = "2001:20::1"; + auto host8 = "2001:0db8:85y3:0000:0000:8a2e:0370:7334"; // Not valid + + QVERIFY(!urlTools()->isIpAddress(host1)); + QVERIFY(urlTools()->isIpAddress(host2)); + QVERIFY(!urlTools()->isIpAddress(host3)); + QVERIFY(urlTools()->isIpAddress(host4)); + QVERIFY(urlTools()->isIpAddress(host5)); + QVERIFY(urlTools()->isIpAddress(host6)); + QVERIFY(urlTools()->isIpAddress(host7)); + QVERIFY(!urlTools()->isIpAddress(host8)); +} + +void TestUrlTools::testIsUrlIdentical() +{ + QVERIFY(urlTools()->isUrlIdentical("https://example.com", "https://example.com")); + QVERIFY(urlTools()->isUrlIdentical("https://example.com", " https://example.com ")); + QVERIFY(!urlTools()->isUrlIdentical("https://example.com", "https://example2.com")); + QVERIFY(!urlTools()->isUrlIdentical("https://example.com/", "https://example.com/#login")); + QVERIFY(urlTools()->isUrlIdentical("https://example.com", "https://example.com/")); + QVERIFY(urlTools()->isUrlIdentical("https://example.com/", "https://example.com")); + QVERIFY(urlTools()->isUrlIdentical("https://example.com/ ", " https://example.com")); + QVERIFY(!urlTools()->isUrlIdentical("https://example.com/", " example.com")); + QVERIFY(urlTools()->isUrlIdentical("https://example.com/path/to/nowhere", "https://example.com/path/to/nowhere/")); + QVERIFY(!urlTools()->isUrlIdentical("https://example.com/", "://example.com/")); + QVERIFY(urlTools()->isUrlIdentical("ftp://127.0.0.1/", "ftp://127.0.0.1")); +} + +void TestUrlTools::testIsUrlValid() +{ + QHash urls; + urls["https://github.com/login"] = true; + urls["https:///github.com/"] = false; + urls["http://github.com/**//*"] = false; + urls["http://*.github.com/login"] = false; + urls["//github.com"] = true; + urls["github.com/{}<>"] = false; + urls["http:/example.com"] = false; + urls["cmd://C:/Toolchains/msys2/usr/bin/mintty \"ssh jon@192.168.0.1:22\""] = true; + urls["file:///Users/testUser/Code/test.html"] = true; + urls["{REF:A@I:46C9B1FFBD4ABC4BBB260C6190BAD20C} "] = true; + + QHashIterator i(urls); + while (i.hasNext()) { + i.next(); + QCOMPARE(urlTools()->isUrlValid(i.key()), i.value()); + } +} diff --git a/tests/TestUrlTools.h b/tests/TestUrlTools.h new file mode 100644 index 0000000000..d26e470406 --- /dev/null +++ b/tests/TestUrlTools.h @@ -0,0 +1,41 @@ +/* + * 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_TESTURLTOOLS_H +#define KEEPASSXC_TESTURLTOOLS_H + +#include "core/UrlTools.h" +#include +#include + +class TestUrlTools : public QObject +{ + Q_OBJECT + +private slots: + void initTestCase(); + void init(); + + void testTopLevelDomain(); + void testIsIpAddress(); + void testIsUrlIdentical(); + void testIsUrlValid(); + +private: + QPointer m_urlTools; +}; +#endif // KEEPASSXC_TESTURLTOOLS_H