Add basic support for WebAuthn (Passkeys) (#8825)

---------

Co-authored-by: varjolintu <sami.vanttinen@protonmail.com>
Co-authored-by: droidmonkey <support@dmapps.us>
This commit is contained in:
Sami Vänttinen 2023-10-25 17:12:55 +03:00 committed by GitHub
parent 378c2992cd
commit 6f2354c0e9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
66 changed files with 4464 additions and 144 deletions

View File

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

View File

@ -1,5 +1,5 @@
KeePassXC - http://www.keepassxc.org/
Copyright (C) 2016-2020 KeePassXC Team <team@keepassxc.org>
Copyright (C) 2016-2023 KeePassXC Team <team@keepassxc.org>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
@ -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

View File

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

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M21,11C21,16.55 17.16,21.74 12,23C6.84,21.74 3,16.55 3,11V5L12,1L21,5V11M12,21C15.75,20 19,15.54 19,11.22V6.3L12,3.18L5,6.3V11.22C5,15.54 8.25,20 12,21M12,6A3,3 0 0,1 15,9C15,10.31 14.17,11.42 13,11.83V14H15V16H13V18H11V11.83C9.83,11.42 9,10.31 9,9A3,3 0 0,1 12,6M12,8A1,1 0 0,0 11,9A1,1 0 0,0 12,10A1,1 0 0,0 13,9A1,1 0 0,0 12,8Z" /></svg>

After

Width:  |  Height:  |  Size: 409 B

View File

@ -59,6 +59,7 @@
<file>application/scalable/actions/object-locked.svg</file>
<file>application/scalable/actions/object-unlocked.svg</file>
<file>application/scalable/actions/paperclip.svg</file>
<file>application/scalable/actions/passkey.svg</file>
<file>application/scalable/actions/password-copy.svg</file>
<file>application/scalable/actions/password-generator.svg</file>
<file>application/scalable/actions/password-show-off.svg</file>

View File

@ -827,10 +827,6 @@ Ctrl+4 - Use Virtual Keyboard (Windows Only)&lt;/p&gt;</source>
</context>
<context>
<name>BrowserEntrySaveDialog</name>
<message>
<source>KeePassXC-Browser Save Entry</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Ok</source>
<translation type="unfinished"></translation>
@ -844,6 +840,65 @@ Ctrl+4 - Use Virtual Keyboard (Windows Only)&lt;/p&gt;</source>
Please select the correct database for saving credentials.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>KeePassXC - Select Database</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>BrowserPasskeysConfirmationDialog</name>
<message>
<source>KeePassXC: Passkey credentials</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Cancel</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Update</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Authenticate</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Register new</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Register</source>
<translation type="unfinished"></translation>
</message>
<message numerus="yes">
<source>Timeout in &lt;b&gt;%n&lt;/b&gt; seconds...</source>
<translation type="unfinished">
<numerusform></numerusform>
<numerusform></numerusform>
</translation>
</message>
<message>
<source>Do you want to register Passkey for:</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>%1 (%2)</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Existing Passkey found.
Do you want to register a new Passkey for:</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Select the existing Passkey and press Update to replace it.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Authenticate Passkey credentials for:</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>BrowserService</name>
@ -900,6 +955,10 @@ Do you want to delete the entry?
</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>%1 (Passkey)</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>BrowserSettingsWidget</name>
@ -5474,6 +5533,18 @@ We recommend you use the AppImage available on our downloads page.</source>
<source>Allow Screen Capture</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Passkeys</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Passkeys</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Import Passkey</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>ManageDatabase</name>
@ -5859,6 +5930,152 @@ We recommend you use the AppImage available on our downloads page.</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>PasskeyExportDialog</name>
<message>
<source>KeePassXC - Passkey Export</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Export the following Passkey entries.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Filenames will be generated with title and .passkey file extension.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Export entries</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Export Selected</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Cancel</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Export to folder</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>PasskeyExporter</name>
<message>
<source>KeePassXC: Passkey Export</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>File &quot;%1.passkey&quot; already exists.
Do you want to overwrite it?
</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Cannot open file</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Cannot open file &quot;%1&quot; for writing.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Cannot write to file</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>PasskeyImportDialog</name>
<message>
<source>KeePassXC - Passkey Import</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Do you want to import the Passkey?</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>URL: %1</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Username: %1</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Use default group (Imported Passkeys)</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Group</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Database</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Select Database</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Import Passkey</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Import</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Cancel</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Database: %1</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Group:</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>PasskeyImporter</name>
<message>
<source>Passkey file</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>All files</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Open Passkey file</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Cannot open file</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Cannot open file &quot;%1&quot; for reading.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Cannot import Passkey</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Cannot import Passkey file &quot;%1&quot;. Data is missing.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Cannot import Passkey file &quot;%1&quot;. Private key is missing or malformed.</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>PasswordEditWidget</name>
<message>
@ -8031,6 +8248,10 @@ Kernel: %3 %4</source>
<source>Failed to decrypt key data.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Passkeys</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>QtIOCompressor</name>
@ -8068,18 +8289,6 @@ Kernel: %3 %4</source>
</context>
<context>
<name>ReportsWidgetBrowserStatistics</name>
<message>
<source>Exclude expired entries from the report</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Show only entries which have URL set</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Show only entries which have browser settings in custom data</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Double-click entries to edit.</source>
<translation type="unfinished"></translation>
@ -8147,17 +8356,25 @@ Kernel: %3 %4</source>
<source>Exclude from reports</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Only show entries that have a URL</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Only show entries that have been explicitly allowed or denied</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Show expired entries</source>
<translation type="unfinished"></translation>
</message>
<message>
<source> (Expired)</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>ReportsWidgetHealthcheck</name>
<message>
<source>Exclude expired entries from the report</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Also show entries that have been excluded from reports</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Hover over reason to show additional details. Double-click entries to edit.</source>
<translation type="unfinished"></translation>
@ -8236,6 +8453,18 @@ Kernel: %3 %4</source>
<source>Exclude from reports</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Show expired entries</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Show entries that have been excluded from reports</source>
<translation type="unfinished"></translation>
</message>
<message>
<source> (Expired)</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>ReportsWidgetHibp</name>
@ -8335,6 +8564,68 @@ Kernel: %3 %4</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>ReportsWidgetPasskeys</name>
<message>
<source>Export</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Import</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>List of entry URLs</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Please wait, list of entries with Passkeys is being updated</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>No entries with Passkeys.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Title</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Path</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Username</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>URLs</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Edit Entry</source>
<translation type="unfinished"></translation>
</message>
<message numerus="yes">
<source>Delete Entry(s)</source>
<translation type="unfinished">
<numerusform></numerusform>
<numerusform></numerusform>
</translation>
</message>
<message>
<source>Relying Party</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Show expired entries</source>
<translation type="unfinished"></translation>
</message>
<message>
<source> (Expired)</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>ReportsWidgetStatistics</name>
<message>

View File

@ -259,6 +259,7 @@ set(keepassx_SOURCES_MAINEXE main.cpp)
add_feature_info(Auto-Type WITH_XC_AUTOTYPE "Automatic password typing")
add_feature_info(Networking WITH_XC_NETWORKING "Compile KeePassXC with network access code (e.g. for downloading website icons)")
add_feature_info(KeePassXC-Browser WITH_XC_BROWSER "Browser integration with KeePassXC-Browser")
add_feature_info(Passkeys WITH_XC_BROWSER_PASSKEYS "Passkeys support for browser integration")
add_feature_info(SSHAgent WITH_XC_SSHAGENT "SSH agent integration compatible with KeeAgent")
add_feature_info(KeeShare WITH_XC_KEESHARE "Sharing integration with KeeShare")
add_feature_info(YubiKey WITH_XC_YUBIKEY "YubiKey HMAC-SHA1 challenge-response")
@ -271,10 +272,21 @@ add_subdirectory(browser)
add_subdirectory(proxy)
if(WITH_XC_BROWSER)
set(keepassxcbrowser_LIB keepassxcbrowser)
set(keepassx_SOURCES ${keepassx_SOURCES} gui/dbsettings/DatabaseSettingsWidgetBrowser.cpp)
set(keepassx_SOURCES ${keepassx_SOURCES} gui/entry/EntryURLModel.cpp)
set(keepassx_SOURCES ${keepassx_SOURCES} gui/reports/ReportsWidgetBrowserStatistics.cpp)
set(keepassx_SOURCES ${keepassx_SOURCES} gui/reports/ReportsPageBrowserStatistics.cpp)
set(keepassx_SOURCES ${keepassx_SOURCES}
gui/dbsettings/DatabaseSettingsWidgetBrowser.cpp
gui/entry/EntryURLModel.cpp
gui/reports/ReportsWidgetBrowserStatistics.cpp
gui/reports/ReportsPageBrowserStatistics.cpp)
endif()
if(WITH_XC_BROWSER_PASSKEYS)
set(keepassx_SOURCES ${keepassx_SOURCES}
gui/reports/ReportsWidgetPasskeys.cpp
gui/reports/ReportsPagePasskeys.cpp
gui/passkeys/PasskeyExporter.cpp
gui/passkeys/PasskeyExportDialog.cpp
gui/passkeys/PasskeyImporter.cpp
gui/passkeys/PasskeyImportDialog.cpp)
endif()
add_subdirectory(autotype)

View File

@ -16,8 +16,8 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#ifndef BROWSERACCESSCONTROLDIALOG_H
#define BROWSERACCESSCONTROLDIALOG_H
#ifndef KEEPASSXC_BROWSERACCESSCONTROLDIALOG_H
#define KEEPASSXC_BROWSERACCESSCONTROLDIALOG_H
#include <QDialog>
#include <QTableWidget>
@ -64,4 +64,4 @@ private:
QList<Entry*> m_entriesToConfirm;
};
#endif // BROWSERACCESSCONTROLDIALOG_H
#endif // KEEPASSXC_BROWSERACCESSCONTROLDIALOG_H

View File

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

View File

@ -15,10 +15,11 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#ifndef BROWSERACTION_H
#define BROWSERACTION_H
#ifndef KEEPASSXC_BROWSERACTION_H
#define KEEPASSXC_BROWSERACTION_H
#include "BrowserMessageBuilder.h"
#include "BrowserService.h"
#include <QJsonArray>
#include <QJsonObject>
@ -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 @@ private:
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 @@ private:
friend class TestBrowser;
};
#endif // BROWSERACTION_H
#endif // KEEPASSXC_BROWSERACTION_H

253
src/browser/BrowserCbor.cpp Normal file
View File

@ -0,0 +1,253 @@
/*
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* 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 <http://www.gnu.org/licenses/>.
*/
#include "BrowserCbor.h"
#include "BrowserMessageBuilder.h"
#include <QCborStreamReader>
#include <QCborStreamWriter>
#include <QJsonDocument>
// 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;
}
}

70
src/browser/BrowserCbor.h Normal file
View File

@ -0,0 +1,70 @@
/*
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* 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 <http://www.gnu.org/licenses/>.
*/
#ifndef KEEPASSXC_BROWSERCBOR_H
#define KEEPASSXC_BROWSERCBOR_H
#include <QByteArray>
#include <QCborArray>
#include <QCborMap>
#include <QJsonObject>
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

View File

@ -16,8 +16,8 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#ifndef BROWSERENTRYCONFIG_H
#define BROWSERENTRYCONFIG_H
#ifndef KEEPASSXC_BROWSERENTRYCONFIG_H
#define KEEPASSXC_BROWSERENTRYCONFIG_H
#include <QObject>
#include <QSet>
@ -55,4 +55,4 @@ private:
QString m_realm;
};
#endif // BROWSERENTRYCONFIG_H
#endif // KEEPASSXC_BROWSERENTRYCONFIG_H

View File

@ -16,8 +16,8 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#ifndef BROWSERENTRYSAVEDIALOG_H
#define BROWSERENTRYSAVEDIALOG_H
#ifndef KEEPASSXC_BROWSERENTRYSAVEDIALOG_H
#define KEEPASSXC_BROWSERENTRYSAVEDIALOG_H
#include "gui/DatabaseTabWidget.h"
@ -45,4 +45,4 @@ private:
QScopedPointer<Ui::BrowserEntrySaveDialog> m_ui;
};
#endif // BROWSERENTRYSAVEDIALOG_H
#endif // KEEPASSXC_BROWSERENTRYSAVEDIALOG_H

View File

@ -11,7 +11,7 @@
</rect>
</property>
<property name="windowTitle">
<string>KeePassXC-Browser Save Entry</string>
<string>KeePassXC - Select Database</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>

View File

@ -15,8 +15,8 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#ifndef NATIVEMESSAGINGHOST_H
#define NATIVEMESSAGINGHOST_H
#ifndef KEEPASSXC_NATIVEMESSAGINGHOST_H
#define KEEPASSXC_NATIVEMESSAGINGHOST_H
#include <QJsonObject>
#include <QObject>
@ -56,4 +56,4 @@ private:
QList<QLocalSocket*> m_socketList;
};
#endif // NATIVEMESSAGINGHOST_H
#endif // KEEPASSXC_NATIVEMESSAGINGHOST_H

View File

@ -19,11 +19,14 @@
#include "BrowserShared.h"
#include "config-keepassx.h"
#include "core/Global.h"
#include "core/Tools.h"
#include <QCryptographicHash>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#ifdef QT_DEBUG
#include <QDebug>
#endif
#include <botan/sodium.h>
@ -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<unsigned char[]> buf(new unsigned char[bytes]);
Botan::Sodium::randombytes_buf(buf.get(), bytes);
return getBase64FromArray(reinterpret_cast<const char*>(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));
}

View File

@ -15,8 +15,8 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#ifndef BROWSERMESSAGEBUILDER_H
#define BROWSERMESSAGEBUILDER_H
#ifndef KEEPASSXC_BROWSERMESSAGEBUILDER_H
#define KEEPASSXC_BROWSERMESSAGEBUILDER_H
#include <QPair>
#include <QString>
@ -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 @@ public:
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

View File

@ -0,0 +1,465 @@
/*
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* 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 <http://www.gnu.org/licenses/>.
*/
#include "BrowserPasskeys.h"
#include "BrowserMessageBuilder.h"
#include "BrowserService.h"
#include "crypto/Random.h"
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QtEndian>
#include <botan/data_src.h>
#include <botan/ec_group.h>
#include <botan/ecdsa.h>
#include <botan/ed25519.h>
#include <botan/pkcs8.h>
#include <botan/pubkey.h>
#include <botan/rsa.h>
#include <botan/sodium.h>
#include <bitset>
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<const uint8_t*>(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<uint8_t> 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<const uint8_t*>(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<const uint8_t*>(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<const uint8_t*>(attToBeSigned.constData()), attToBeSigned.size());
rawSignature = signer.signature(*randomGen()->getRng());
} else {
qWarning("BrowserWebAuthn::buildSignature: Algorithm not supported");
return {};
}
auto signature = QByteArray(reinterpret_cast<char*>(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<quint16>(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<int>(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<uint8_t>(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<WebAuthnAlgorithms>(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);
}

View File

@ -0,0 +1,143 @@
/*
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* 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 <http://www.gnu.org/licenses/>.
*/
#ifndef BROWSERPASSKEYS_H
#define BROWSERPASSKEYS_H
#include "BrowserCbor.h"
#include <QJsonObject>
#include <QObject>
#include <botan/asn1_obj.h>
#include <botan/bigint.h>
#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

View File

@ -0,0 +1,153 @@
/*
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* 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 <http://www.gnu.org/licenses/>.
*/
#include "BrowserPasskeysConfirmationDialog.h"
#include "ui_BrowserPasskeysConfirmationDialog.h"
#include "core/Entry.h"
#include <QCloseEvent>
#include <QUrl>
#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<Entry*>& 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<Entry*>& 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 <b>%n</b> seconds...", "", m_ui->progressBar->maximum() - m_counter));
}
void BrowserPasskeysConfirmationDialog::updateEntriesToTable(const QList<Entry*>& 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);
}

View File

@ -0,0 +1,66 @@
/*
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* 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 <http://www.gnu.org/licenses/>.
*/
#ifndef KEEPASSXC_BROWSERPASSKEYSCONFIRMATIONDIALOG_H
#define KEEPASSXC_BROWSERPASSKEYSCONFIRMATIONDIALOG_H
#include <QDialog>
#include <QTableWidget>
#include <QTimer>
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<Entry*>& existingEntries,
int timeout);
void authenticateCredential(const QList<Entry*>& 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<Entry*>& entries);
private:
QScopedPointer<Ui::BrowserPasskeysConfirmationDialog> m_ui;
QList<Entry*> m_entries;
QTimer m_timer;
int m_counter;
bool m_passkeyUpdated;
};
#endif // KEEPASSXC_BROWSERPASSKEYSCONFIRMATIONDIALOG_H

View File

@ -0,0 +1,159 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>BrowserPasskeysConfirmationDialog</class>
<widget class="QDialog" name="BrowserPasskeysConfirmationDialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>405</width>
<height>282</height>
</rect>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Minimum">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="windowTitle">
<string>KeePassXC: Passkey credentials</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QLabel" name="firstLabel">
<property name="font">
<font>
<bold>true</bold>
</font>
</property>
<property name="alignment">
<set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop</set>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="dataLabel">
<property name="textFormat">
<enum>Qt::PlainText</enum>
</property>
<property name="alignment">
<set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop</set>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="secondLabel">
<property name="font">
<font>
<bold>true</bold>
</font>
</property>
<property name="alignment">
<set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop</set>
</property>
</widget>
</item>
<item>
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeType">
<enum>QSizePolicy::Expanding</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>10</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QTableWidget" name="credentialsTable">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="editTriggers">
<set>QAbstractItemView::NoEditTriggers</set>
</property>
<property name="showDropIndicator" stdset="0">
<bool>false</bool>
</property>
<property name="selectionMode">
<enum>QAbstractItemView::SingleSelection</enum>
</property>
<property name="cornerButtonEnabled">
<bool>false</bool>
</property>
<attribute name="horizontalHeaderVisible">
<bool>false</bool>
</attribute>
<attribute name="verticalHeaderVisible">
<bool>false</bool>
</attribute>
</widget>
</item>
<item>
<widget class="QProgressBar" name="progressBar">
<property name="value">
<number>0</number>
</property>
<property name="textVisible">
<bool>false</bool>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="timeoutLabel"/>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QPushButton" name="cancelButton">
<property name="text">
<string>Cancel</string>
</property>
<property name="default">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="updateButton">
<property name="text">
<string>Update</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="confirmButton">
<property name="text">
<string>Authenticate</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View File

@ -29,6 +29,10 @@
#include "gui/MainWindow.h"
#include "gui/MessageBox.h"
#include "gui/osutils/OSUtils.h"
#ifdef WITH_XC_BROWSER_PASSKEYS
#include "BrowserPasskeys.h"
#include "BrowserPasskeysConfirmationDialog.h"
#endif
#ifdef Q_OS_MACOS
#include "gui/osutils/macutils/MacUtils.h"
#endif
@ -48,6 +52,9 @@ const QString BrowserService::KEEPASSXCBROWSER_NAME = QStringLiteral("KeePassXC-
const QString BrowserService::KEEPASSXCBROWSER_OLD_NAME = QStringLiteral("keepassxc-browser Settings");
static const QString KEEPASSXCBROWSER_GROUP_NAME = QStringLiteral("KeePassXC-Browser Passwords");
static int KEEPASSXCBROWSER_DEFAULT_ICON = 1;
#ifdef WITH_XC_BROWSER_PASSKEYS
static int KEEPASSXCBROWSER_PASSKEY_ICON = 13;
#endif
// These are for the settings and password conversion
static const QString KEEPASSHTTP_NAME = QStringLiteral("KeePassHttp Settings");
static const QString KEEPASSHTTP_GROUP_NAME = QStringLiteral("KeePassHttp Passwords");
@ -607,6 +614,177 @@ QString BrowserService::getKey(const QString& id)
return db->metadata()->customData()->value(CustomData::BrowserKeyPrefix + id);
}
#ifdef WITH_XC_BROWSER_PASSKEYS
// Passkey registration
QJsonObject BrowserService::showPasskeysRegisterPrompt(const QJsonObject& publicKey,
const QString& origin,
const StringPairList& keyList)
{
auto db = selectedDatabase();
if (!db) {
return getPasskeyError(ERROR_KEEPASS_DATABASE_NOT_OPENED);
}
const auto userJson = publicKey["user"].toObject();
const auto username = userJson["name"].toString();
const auto userHandle = userJson["id"].toString();
const auto rpId = publicKey["rp"]["id"].toString();
const auto rpName = publicKey["rp"]["name"].toString();
const auto timeoutValue = publicKey["timeout"].toInt();
const auto excludeCredentials = publicKey["excludeCredentials"].toArray();
const auto attestation = publicKey["attestation"].toString();
// Only support these two for now
if (attestation != BrowserPasskeys::PASSKEYS_ATTESTATION_NONE
&& attestation != BrowserPasskeys::PASSKEYS_ATTESTATION_DIRECT) {
return getPasskeyError(ERROR_PASSKEYS_ATTESTATION_NOT_SUPPORTED);
}
const auto authenticatorSelection = publicKey["authenticatorSelection"].toObject();
const auto userVerification = authenticatorSelection["userVerification"].toString();
if (!browserPasskeys()->isUserVerificationValid(userVerification)) {
return getPasskeyError(ERROR_PASSKEYS_INVALID_USER_VERIFICATION);
}
if (!excludeCredentials.isEmpty() && isPasskeyCredentialExcluded(excludeCredentials, origin, keyList)) {
return getPasskeyError(ERROR_PASSKEYS_CREDENTIAL_IS_EXCLUDED);
}
const auto existingEntries = getPasskeyEntries(rpId, keyList);
const auto timeout = browserPasskeys()->getTimeout(userVerification, timeoutValue);
raiseWindow();
BrowserPasskeysConfirmationDialog confirmDialog;
confirmDialog.registerCredential(username, rpId, existingEntries, timeout);
auto dialogResult = confirmDialog.exec();
if (dialogResult == QDialog::Accepted) {
const auto publicKeyCredentials = browserPasskeys()->buildRegisterPublicKeyCredential(publicKey, origin);
if (confirmDialog.isPasskeyUpdated()) {
addPasskeyToEntry(confirmDialog.getSelectedEntry(),
rpId,
rpName,
username,
publicKeyCredentials.id,
userHandle,
publicKeyCredentials.key);
} else {
addPasskeyToGroup(
nullptr, origin, rpId, rpName, username, publicKeyCredentials.id, userHandle, publicKeyCredentials.key);
}
hideWindow();
return publicKeyCredentials.response;
}
hideWindow();
return getPasskeyError(ERROR_PASSKEYS_REQUEST_CANCELED);
}
// Passkey authentication
QJsonObject BrowserService::showPasskeysAuthenticationPrompt(const QJsonObject& publicKey,
const QString& origin,
const StringPairList& keyList)
{
auto db = selectedDatabase();
if (!db) {
return getPasskeyError(ERROR_KEEPASS_DATABASE_NOT_OPENED);
}
const auto userVerification = publicKey["userVerification"].toString();
if (!browserPasskeys()->isUserVerificationValid(userVerification)) {
return getPasskeyError(ERROR_PASSKEYS_INVALID_USER_VERIFICATION);
}
// Parse "allowCredentials"
const auto rpId = publicKey["rpId"].toString();
const auto entries = getPasskeyAllowedEntries(publicKey, rpId, keyList);
if (entries.isEmpty()) {
return getPasskeyError(ERROR_KEEPASS_NO_LOGINS_FOUND);
}
// With single entry, if no verification is needed, return directly
if (entries.count() == 1 && userVerification == BrowserPasskeys::REQUIREMENT_DISCOURAGED) {
return getPublicKeyCredentialFromEntry(entries.first(), publicKey, origin);
}
const auto timeout = publicKey["timeout"].toInt();
raiseWindow();
BrowserPasskeysConfirmationDialog confirmDialog;
confirmDialog.authenticateCredential(entries, origin, timeout);
auto dialogResult = confirmDialog.exec();
if (dialogResult == QDialog::Accepted) {
hideWindow();
const auto selectedEntry = confirmDialog.getSelectedEntry();
return getPublicKeyCredentialFromEntry(selectedEntry, publicKey, origin);
}
hideWindow();
return getPasskeyError(ERROR_PASSKEYS_REQUEST_CANCELED);
}
void BrowserService::addPasskeyToGroup(Group* group,
const QString& url,
const QString& rpId,
const QString& rpName,
const QString& username,
const QString& userId,
const QString& userHandle,
const QString& privateKey)
{
// If no group provided, use the default browser group of the selected database
if (!group) {
auto db = selectedDatabase();
if (!db) {
return;
}
group = getDefaultEntryGroup(db);
}
auto* entry = new Entry();
entry->setUuid(QUuid::createUuid());
entry->setGroup(group);
entry->setTitle(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,
@ -621,7 +799,7 @@ void BrowserService::addEntry(const EntryParameters& entryParameters,
auto* entry = new Entry();
entry->setUuid(QUuid::createUuid());
entry->setTitle(QUrl(entryParameters.siteUrl).host());
entry->setTitle(entryParameters.title.isEmpty() ? QUrl(entryParameters.siteUrl).host() : entryParameters.title);
entry->setUrl(entryParameters.siteUrl);
entry->setIcon(KEEPASSXCBROWSER_DEFAULT_ICON);
entry->setUsername(entryParameters.login);
@ -667,7 +845,7 @@ bool BrowserService::updateEntry(const EntryParameters& entryParameters, const Q
return false;
}
Entry* entry = db->rootGroup()->findEntryByUuid(Tools::hexToUuid(uuid));
auto entry = db->rootGroup()->findEntryByUuid(Tools::hexToUuid(uuid));
if (!entry) {
// If entry is not found for update, add a new one to the selected database
addEntry(entryParameters, "", "", false, db);
@ -746,8 +924,10 @@ bool BrowserService::deleteEntry(const QString& uuid)
return true;
}
QList<Entry*>
BrowserService::searchEntries(const QSharedPointer<Database>& db, const QString& siteUrl, const QString& formUrl)
QList<Entry*> BrowserService::searchEntries(const QSharedPointer<Database>& db,
const QString& siteUrl,
const QString& formUrl,
bool passkey)
{
QList<Entry*> entries;
auto* rootGroup = db->rootGroup();
@ -771,10 +951,17 @@ BrowserService::searchEntries(const QSharedPointer<Database>& 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)) {
entries.append(entry);
@ -785,8 +972,10 @@ BrowserService::searchEntries(const QSharedPointer<Database>& db, const QString&
return entries;
}
QList<Entry*>
BrowserService::searchEntries(const QString& siteUrl, const QString& formUrl, const StringPairList& keyList)
QList<Entry*> 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<Database>& db) {
@ -820,7 +1009,7 @@ BrowserService::searchEntries(const QString& siteUrl, const QString& formUrl, co
QList<Entry*> entries;
do {
for (const auto& db : databases) {
entries << searchEntries(db, siteUrl, formUrl);
entries << searchEntries(db, siteUrl, formUrl, passkey);
}
} while (entries.isEmpty() && removeFirstDomain(hostname));
@ -1094,6 +1283,74 @@ bool BrowserService::shouldIncludeEntry(Entry* entry,
return false;
}
#ifdef WITH_XC_BROWSER_PASSKEYS
// Returns all Passkey entries for the current Relying Party
QList<Entry*> BrowserService::getPasskeyEntries(const QString& rpId, const StringPairList& keyList)
{
QList<Entry*> entries;
for (const auto& entry : searchEntries(rpId, "", keyList, true)) {
if (entry->attributes()->hasKey(BrowserPasskeys::KPEX_PASSKEY_PRIVATE_KEY_PEM)
&& entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_RELYING_PARTY) == rpId) {
entries << entry;
}
}
return entries;
}
// Get all entries for the site that are allowed by the server
QList<Entry*> BrowserService::getPasskeyAllowedEntries(const QJsonObject& publicKey,
const QString& rpId,
const StringPairList& keyList)
{
QList<Entry*> 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,

View File

@ -17,10 +17,11 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#ifndef BROWSERSERVICE_H
#define BROWSERSERVICE_H
#ifndef KEEPASSXC_BROWSERSERVICE_H
#define KEEPASSXC_BROWSERSERVICE_H
#include "BrowserAccessControlDialog.h"
#include "config-keepassx.h"
#include "core/Entry.h"
#include "gui/PasswordGeneratorWidget.h"
@ -45,6 +46,7 @@ struct KeyPairMessage
struct EntryParameters
{
QString dbid;
QString title;
QString login;
QString password;
QString realm;
@ -82,7 +84,30 @@ public:
QString getCurrentTotp(const QString& uuid);
void showPasswordGenerator(const KeyPairMessage& keyPairMessage);
bool isPasswordGeneratorRequested() const;
bool isUrlIdentical(const QString& first, const QString& second) const;
QSharedPointer<Database> selectedDatabase();
#ifdef WITH_XC_BROWSER_PASSKEYS
QJsonObject
showPasskeysRegisterPrompt(const QJsonObject& publicKey, const QString& origin, const StringPairList& keyList);
QJsonObject showPasskeysAuthenticationPrompt(const QJsonObject& publicKey,
const QString& origin,
const StringPairList& keyList);
void addPasskeyToGroup(Group* group,
const QString& url,
const QString& rpId,
const QString& rpName,
const QString& username,
const QString& userId,
const QString& userHandle,
const QString& privateKey);
void addPasskeyToEntry(Entry* entry,
const QString& rpId,
const QString& rpName,
const QString& username,
const QString& userId,
const QString& userHandle,
const QString& privateKey);
#endif
void addEntry(const EntryParameters& entryParameters,
const QString& group,
const QString& groupUuid,
@ -129,8 +154,12 @@ private:
Hidden
};
QList<Entry*> searchEntries(const QSharedPointer<Database>& db, const QString& siteUrl, const QString& formUrl);
QList<Entry*> searchEntries(const QString& siteUrl, const QString& formUrl, const StringPairList& keyList);
QList<Entry*> searchEntries(const QSharedPointer<Database>& db,
const QString& siteUrl,
const QString& formUrl,
bool passkey = false);
QList<Entry*>
searchEntries(const QString& siteUrl, const QString& formUrl, const StringPairList& keyList, bool passkey = false);
QList<Entry*> sortEntries(QList<Entry*>& entries, const QString& siteUrl, const QString& formUrl);
QList<Entry*> confirmEntries(QList<Entry*>& entriesToConfirm,
const EntryParameters& entryParameters,
@ -148,12 +177,22 @@ private:
bool removeFirstDomain(QString& hostname);
bool
shouldIncludeEntry(Entry* entry, const QString& url, const QString& submitUrl, const bool omitWwwSubdomain = false);
#ifdef WITH_XC_BROWSER_PASSKEYS
QList<Entry*> getPasskeyEntries(const QString& rpId, const StringPairList& keyList);
QList<Entry*>
getPasskeyAllowedEntries(const QJsonObject& publicKey, const QString& rpId, const StringPairList& keyList);
QJsonObject
getPublicKeyCredentialFromEntry(const Entry* entry, const QJsonObject& publicKey, const QString& origin);
bool isPasskeyCredentialExcluded(const QJsonArray& excludeCredentials,
const QString& origin,
const StringPairList& keyList);
QJsonObject getPasskeyError(int errorCode) const;
#endif
bool handleURL(const QString& entryUrl,
const QString& siteUrl,
const QString& formUrl,
const bool omitWwwSubdomain = false);
QSharedPointer<Database> getDatabase();
QSharedPointer<Database> selectedDatabase();
QString getDatabaseRootUuid();
QString getDatabaseRecycleBinUuid();
bool checkLegacySettings(QSharedPointer<Database> db);
@ -176,6 +215,9 @@ private:
Q_DISABLE_COPY(BrowserService);
friend class TestBrowser;
#ifdef WITH_XC_BROWSER_PASSKEYS
friend class TestPasskeys;
#endif
};
static inline BrowserService* browserService()
@ -183,4 +225,4 @@ static inline BrowserService* browserService()
return BrowserService::instance();
}
#endif // BROWSERSERVICE_H
#endif // KEEPASSXC_BROWSERSERVICE_H

View File

@ -17,8 +17,8 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#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

View File

@ -1,5 +1,4 @@
# Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
# Copyright (C) 2017 Sami Vänttinen <sami.vanttinen@protonmail.com>
#
# 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})

View File

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

View File

@ -1,6 +1,6 @@
/*
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
* Copyright (C) 2012 Felix Geyer <debfx@fobos.de>
* Copyright (C) 2017 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -17,7 +17,6 @@
*/
#include "EntryAttributes.h"
#include "core/Global.h"
#include <QRegularExpression>
@ -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<QString> EntryAttributes::customKeys() const
QList<QString> customKeys;
const QList<QString> 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);
}

View File

@ -61,7 +61,9 @@ public:
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;

View File

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

View File

@ -85,6 +85,7 @@ public:
QString name() const;
QString notes() const;
QString tags() const;
QString fullPath() const;
int iconNumber() const;
const QUuid& iconUuid() const;
const TimeInfo& timeInfo() const;

View File

@ -94,6 +94,9 @@ namespace Tools
#ifdef WITH_XC_BROWSER
extensions += "\n- " + QObject::tr("Browser Integration");
#endif
#ifdef WITH_XC_BROWSER_PASSKEYS
extensions += "\n- " + QObject::tr("Passkeys");
#endif
#ifdef WITH_XC_SSHAGENT
extensions += "\n- " + QObject::tr("SSH Agent");
#endif
@ -408,6 +411,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;

View File

@ -43,6 +43,7 @@ namespace Tools
bool isValidUuid(const QString& uuidStr);
QString envSubstitute(const QString& filepath,
QProcessEnvironment environment = QProcessEnvironment::systemEnvironment());
QString cleanFilename(QString filename);
/**
* Escapes all characters in regex such that they do not receive any special treatment when used

View File

@ -556,6 +556,18 @@ void DatabaseTabWidget::showDatabaseSettings()
currentDatabaseWidget()->switchToDatabaseSettings();
}
#ifdef WITH_XC_BROWSER_PASSKEYS
void DatabaseTabWidget::showPasskeys()
{
currentDatabaseWidget()->switchToPasskeys();
}
void DatabaseTabWidget::importPasskey()
{
currentDatabaseWidget()->switchToImportPasskey();
}
#endif
bool DatabaseTabWidget::isModified(int index) const
{
if (count() == 0) {

View File

@ -19,6 +19,7 @@
#define KEEPASSX_DATABASETABWIDGET_H
#include "DatabaseOpenDialog.h"
#include "config-keepassx.h"
#include "gui/MessageWidget.h"
#include <QTabWidget>
@ -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();

View File

@ -64,6 +64,10 @@
#include "sshagent/SSHAgent.h"
#endif
#ifdef WITH_XC_BROWSER_PASSKEYS
#include "gui/passkeys/PasskeyImporter.h"
#endif
DatabaseWidget::DatabaseWidget(QSharedPointer<Database> 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()) {

View File

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

View File

@ -438,6 +438,11 @@ MainWindow::MainWindow()
m_ui->actionKeyboardShortcuts->setIcon(icons()->icon("keyboard-shortcuts"));
m_ui->actionCheckForUpdates->setIcon(icons()->icon("system-software-update"));
#ifdef WITH_XC_BROWSER_PASSKEYS
m_ui->actionPasskeys->setIcon(icons()->icon("passkey"));
m_ui->actionImportPasskey->setIcon(icons()->icon("document-import"));
#endif
m_actionMultiplexer.connect(
SIGNAL(currentModeChanged(DatabaseWidget::Mode)), this, SLOT(setMenuActionState(DatabaseWidget::Mode)));
m_actionMultiplexer.connect(SIGNAL(groupChanged()), this, SLOT(setMenuActionState()));
@ -483,6 +488,10 @@ MainWindow::MainWindow()
connect(m_ui->actionDatabaseSecurity, SIGNAL(triggered()), m_ui->tabWidget, SLOT(showDatabaseSecurity()));
connect(m_ui->actionReports, SIGNAL(triggered()), m_ui->tabWidget, SLOT(showDatabaseReports()));
connect(m_ui->actionDatabaseSettings, SIGNAL(triggered()), m_ui->tabWidget, SLOT(showDatabaseSettings()));
#ifdef WITH_XC_BROWSER_PASSKEYS
connect(m_ui->actionPasskeys, SIGNAL(triggered()), m_ui->tabWidget, SLOT(showPasskeys()));
connect(m_ui->actionImportPasskey, SIGNAL(triggered()), m_ui->tabWidget, SLOT(importPasskey()));
#endif
connect(m_ui->actionImportCsv, SIGNAL(triggered()), m_ui->tabWidget, SLOT(importCsv()));
connect(m_ui->actionImportKeePass1, SIGNAL(triggered()), m_ui->tabWidget, SLOT(importKeePass1Database()));
connect(m_ui->actionImportOpVault, SIGNAL(triggered()), m_ui->tabWidget, SLOT(importOpVaultDatabase()));
@ -977,6 +986,10 @@ void MainWindow::setMenuActionState(DatabaseWidget::Mode mode)
m_ui->actionExportHtml->setEnabled(true);
m_ui->actionExportXML->setEnabled(true);
m_ui->actionDatabaseMerge->setEnabled(m_ui->tabWidget->currentIndex() != -1);
#ifdef WITH_XC_BROWSER_PASSKEYS
m_ui->actionPasskeys->setEnabled(true);
m_ui->actionImportPasskey->setEnabled(true);
#endif
#ifdef WITH_XC_SSHAGENT
bool singleEntryHasSshKey =
singleEntrySelected && sshAgent()->isEnabled() && dbWidget->currentEntryHasSshKey();
@ -1044,6 +1057,14 @@ void MainWindow::setMenuActionState(DatabaseWidget::Mode mode)
m_ui->actionEntryRemoveFromAgent->setVisible(false);
m_ui->actionGroupEmptyRecycleBin->setVisible(false);
#ifdef WITH_XC_BROWSER_PASSKEYS
m_ui->actionPasskeys->setEnabled(false);
m_ui->actionImportPasskey->setEnabled(false);
#else
m_ui->actionPasskeys->setVisible(false);
m_ui->actionImportPasskey->setVisible(false);
#endif
m_searchWidgetAction->setEnabled(false);
break;
}

View File

@ -262,6 +262,9 @@
<addaction name="actionDatabaseSettings"/>
<addaction name="actionDatabaseSecurity"/>
<addaction name="separator"/>
<addaction name="actionPasskeys"/>
<addaction name="actionImportPasskey"/>
<addaction name="separator"/>
<addaction name="actionDatabaseMerge"/>
<addaction name="menuImport"/>
<addaction name="menuExport"/>
@ -620,6 +623,34 @@
<enum>QAction::NoRole</enum>
</property>
</action>
<action name="actionPasskeys">
<property name="enabled">
<bool>false</bool>
</property>
<property name="text">
<string>Passkeys…</string>
</property>
<property name="toolTip">
<string>Passkeys</string>
</property>
<property name="menuRole">
<enum>QAction::NoRole</enum>
</property>
</action>
<action name="actionImportPasskey">
<property name="enabled">
<bool>false</bool>
</property>
<property name="text">
<string>Import Passkey</string>
</property>
<property name="toolTip">
<string>Import Passkey</string>
</property>
<property name="menuRole">
<enum>QAction::NoRole</enum>
</property>
</action>
<action name="actionEntryClone">
<property name="enabled">
<bool>false</bool>

View File

@ -0,0 +1,91 @@
/*
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* 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 <http://www.gnu.org/licenses/>.
*/
#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<Entry*>& 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<QTableWidgetItem*> PasskeyExportDialog::getSelectedItems() const
{
QList<QTableWidgetItem*> 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"));
}

View File

@ -0,0 +1,52 @@
/*
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* 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 <http://www.gnu.org/licenses/>.
*/
#ifndef KEEPASSXC_PASSKEYEXPORTDIALOG_H
#define KEEPASSXC_PASSKEYEXPORTDIALOG_H
#include <QDialog>
#include <QTableWidget>
class Entry;
namespace Ui
{
class PasskeyExportDialog;
}
class PasskeyExportDialog : public QDialog
{
Q_OBJECT
public:
explicit PasskeyExportDialog(QWidget* parent = nullptr);
~PasskeyExportDialog() override;
void setEntries(const QList<Entry*>& items);
QList<QTableWidgetItem*> getSelectedItems() const;
QString selectExportFolder();
private slots:
void selectionChanged();
private:
QScopedPointer<Ui::PasskeyExportDialog> m_ui;
QList<Entry*> m_entriesToConfirm;
QList<Entry*> m_allowedEntries;
};
#endif // KEEPASSXC_PASSKEYEXPORTDIALOG_H

View File

@ -0,0 +1,121 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>PasskeyExportDialog</class>
<widget class="QDialog" name="PasskeyExportDialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>540</width>
<height>320</height>
</rect>
</property>
<property name="windowTitle">
<string>KeePassXC - Passkey Export</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QLabel" name="exportLabel">
<property name="font">
<font>
<weight>75</weight>
<bold>true</bold>
</font>
</property>
<property name="text">
<string>Export the following Passkey entries.</string>
</property>
<property name="alignment">
<set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="filenameLabel">
<property name="font">
<font>
<weight>75</weight>
<bold>true</bold>
</font>
</property>
<property name="text">
<string>Filenames will be generated with title and .passkey file extension.</string>
</property>
<property name="alignment">
<set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QTableWidget" name="itemsTable">
<property name="editTriggers">
<set>QAbstractItemView::NoEditTriggers</set>
</property>
<property name="showDropIndicator" stdset="0">
<bool>false</bool>
</property>
<property name="selectionMode">
<enum>QAbstractItemView::ExtendedSelection</enum>
</property>
<property name="selectionBehavior">
<enum>QAbstractItemView::SelectRows</enum>
</property>
<property name="cornerButtonEnabled">
<bool>false</bool>
</property>
<attribute name="horizontalHeaderVisible">
<bool>false</bool>
</attribute>
<attribute name="verticalHeaderVisible">
<bool>false</bool>
</attribute>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QPushButton" name="exportButton">
<property name="accessibleName">
<string>Export entries</string>
</property>
<property name="text">
<string>Export Selected</string>
</property>
<property name="autoDefault">
<bool>true</bool>
</property>
<property name="default">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="cancelButton">
<property name="text">
<string>Cancel</string>
</property>
<property name="autoDefault">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View File

@ -0,0 +1,105 @@
/*
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* 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 <http://www.gnu.org/licenses/>.
*/
#include "PasskeyExporter.h"
#include "PasskeyExportDialog.h"
#include "browser/BrowserPasskeys.h"
#include "core/Entry.h"
#include "core/Tools.h"
#include "gui/MessageBox.h"
#include <QFile>
#include <QJsonDocument>
#include <QJsonObject>
void PasskeyExporter::showExportDialog(const QList<Entry*>& 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": <private key>,
* "relyingParty: <relying party>,
* "url": <URL>,
* "userHandle": <user handle>,
* "userId": <generated user id>,
* "username:" <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();
}

View File

@ -0,0 +1,39 @@
/*
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* 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 <http://www.gnu.org/licenses/>.
*/
#ifndef KEEPASSXC_PASSKEYEXPORTER_H
#define KEEPASSXC_PASSKEYEXPORTER_H
#include <QList>
#include <QObject>
class Entry;
class PasskeyExporter : public QObject
{
Q_OBJECT
public:
explicit PasskeyExporter() = default;
void showExportDialog(const QList<Entry*>& items);
private:
void exportSelectedEntry(const Entry* entry, const QString& folder);
};
#endif // KEEPASSXC_PASSKEYEXPORTER_H

View File

@ -0,0 +1,121 @@
/*
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* 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 <http://www.gnu.org/licenses/>.
*/
#include "PasskeyImportDialog.h"
#include "ui_PasskeyImportDialog.h"
#include "browser/BrowserService.h"
#include "core/Metadata.h"
#include "gui/MainWindow.h"
#include <QCloseEvent>
#include <QFileInfo>
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>& 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<Database> PasskeyImportDialog::getSelectedDatabase()
{
return m_selectedDatabase;
}
QUuid PasskeyImportDialog::getSelectedGroupUuid()
{
return m_selectedGroupUuid;
}
bool PasskeyImportDialog::useDefaultGroup()
{
return m_useDefaultGroup;
}
QString PasskeyImportDialog::getDatabaseName(const QSharedPointer<Database>& database) const
{
return QFileInfo(database->filePath()).fileName();
}
void PasskeyImportDialog::addGroups(const QSharedPointer<Database>& 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<QUuid>();
}
void PasskeyImportDialog::useDefaultGroupChanged()
{
m_ui->selectGroupComboBox->setEnabled(!m_ui->useDefaultGroupCheckbox->isChecked());
m_useDefaultGroup = m_ui->useDefaultGroupCheckbox->isChecked();
}

View File

@ -0,0 +1,60 @@
/*
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* 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 <http://www.gnu.org/licenses/>.
*/
#ifndef KEEPASSXC_PASSKEYIMPORTDIALOG_H
#define KEEPASSXC_PASSKEYIMPORTDIALOG_H
#include "core/Database.h"
#include "core/Group.h"
#include <QDialog>
#include <QUuid>
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>& database);
QSharedPointer<Database> getSelectedDatabase();
QUuid getSelectedGroupUuid();
bool useDefaultGroup();
private:
QString getDatabaseName(const QSharedPointer<Database>& database) const;
void addGroups(const QSharedPointer<Database>& database);
private slots:
void selectDatabase();
void changeGroup(int index);
void useDefaultGroupChanged();
private:
QScopedPointer<Ui::PasskeyImportDialog> m_ui;
QSharedPointer<Database> m_selectedDatabase;
QUuid m_selectedGroupUuid;
bool m_useDefaultGroup;
};
#endif // KEEPASSXC_PASSKEYIMPORTDIALOG_H

View File

@ -0,0 +1,174 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>PasskeyImportDialog</class>
<widget class="QDialog" name="PasskeyImportDialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>405</width>
<height>227</height>
</rect>
</property>
<property name="windowTitle">
<string>KeePassXC - Passkey Import</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>
<widget class="QLabel" name="infoLabel">
<property name="font">
<font>
<bold>true</bold>
</font>
</property>
<property name="text">
<string>Do you want to import the Passkey?</string>
</property>
<property name="alignment">
<set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="urlLabel">
<property name="text">
<string>URL: %1</string>
</property>
<property name="alignment">
<set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="usernameLabel">
<property name="text">
<string>Username: %1</string>
</property>
<property name="alignment">
<set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="useDefaultGroupCheckbox">
<property name="text">
<string>Use default group (Imported Passkeys)</string>
</property>
<property name="checked">
<bool>false</bool>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="selectGroupHorLayout">
<item>
<widget class="QLabel" name="selectGroupLabel">
<property name="text">
<string>Group</string>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_2">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QComboBox" name="selectGroupComboBox"/>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="selectDatabaseHorLayout">
<item>
<widget class="QLabel" name="selectDatabaseLabel">
<property name="text">
<string>Database</string>
</property>
<property name="scaledContents">
<bool>false</bool>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_3">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QPushButton" name="selectDatabaseButton">
<property name="text">
<string>Select Database</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QPushButton" name="importButton">
<property name="accessibleName">
<string>Import Passkey</string>
</property>
<property name="text">
<string>Import</string>
</property>
<property name="autoDefault">
<bool>true</bool>
</property>
<property name="default">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="cancelButton">
<property name="text">
<string>Cancel</string>
</property>
<property name="autoDefault">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View File

@ -0,0 +1,139 @@
/*
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* 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 <http://www.gnu.org/licenses/>.
*/
#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 <QFileInfo>
#include <QUuid>
static const QString IMPORTED_PASSKEYS_GROUP = QStringLiteral("Imported Passkeys");
void PasskeyImporter::importPasskey(QSharedPointer<Database>& 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>& 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>& 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>& 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;
}

View File

@ -0,0 +1,48 @@
/*
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* 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 <http://www.gnu.org/licenses/>.
*/
#ifndef KEEPASSXC_PASSKEYIMPORTER_H
#define KEEPASSXC_PASSKEYIMPORTER_H
#include "core/Database.h"
#include <QFile>
#include <QObject>
class Entry;
class PasskeyImporter : public QObject
{
Q_OBJECT
public:
explicit PasskeyImporter() = default;
void importPasskey(QSharedPointer<Database>& database);
private:
void importSelectedFile(QFile& file, QSharedPointer<Database>& database);
void showImportDialog(QSharedPointer<Database>& database,
const QString& url,
const QString& relyingParty,
const QString& username,
const QString& userId,
const QString& userHandle,
const QString& privateKey);
Group* getDefaultGroup(QSharedPointer<Database>& database);
};
#endif // KEEPASSXC_PASSKEYIMPORTER_H

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2021 KeePassXC Team <team@keepassxc.org>
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -25,6 +25,10 @@
#include "ReportsPageBrowserStatistics.h"
#include "ReportsWidgetBrowserStatistics.h"
#endif
#ifdef WITH_XC_BROWSER_PASSKEYS
#include "ReportsPagePasskeys.h"
#include "ReportsWidgetPasskeys.h"
#endif
#include "ReportsWidgetHealthcheck.h"
#include "ReportsWidgetHibp.h"
@ -61,6 +65,9 @@ ReportsDialog::ReportsDialog(QWidget* parent)
, m_statPage(new ReportsPageStatistics())
#ifdef WITH_XC_BROWSER
, m_browserStatPage(new ReportsPageBrowserStatistics())
#endif
#ifdef WITH_XC_BROWSER_PASSKEYS
, m_passkeysPage(new ReportsPagePasskeys())
#endif
, m_editEntryWidget(new EditEntryWidget(this))
{
@ -68,10 +75,13 @@ ReportsDialog::ReportsDialog(QWidget* parent)
connect(m_ui->buttonBox, SIGNAL(rejected()), SLOT(reject()));
addPage(m_statPage);
addPage(m_healthPage);
#ifdef WITH_XC_BROWSER_PASSKEYS
addPage(m_passkeysPage);
#endif
#ifdef WITH_XC_BROWSER
addPage(m_browserStatPage);
#endif
addPage(m_healthPage);
addPage(m_hibpPage);
m_ui->stackedWidget->setCurrentIndex(0);
@ -88,6 +98,10 @@ ReportsDialog::ReportsDialog(QWidget* parent)
connect(m_browserStatPage->m_browserWidget,
SIGNAL(entryActivated(Entry*)),
SLOT(entryActivationSignalReceived(Entry*)));
#endif
#ifdef WITH_XC_BROWSER_PASSKEYS
connect(
m_passkeysPage->m_passkeysWidget, SIGNAL(entryActivated(Entry*)), SLOT(entryActivationSignalReceived(Entry*)));
#endif
connect(m_editEntryWidget, SIGNAL(editFinished(bool)), SLOT(switchToMainView(bool)));
}
@ -114,6 +128,15 @@ void ReportsDialog::addPage(QSharedPointer<IReportsPage> page)
m_ui->categoryList->setCurrentCategory(category);
}
#ifdef WITH_XC_BROWSER_PASSKEYS
void ReportsDialog::activatePasskeysPage()
{
m_ui->stackedWidget->setCurrentWidget(m_passkeysPage->m_passkeysWidget);
auto index = m_ui->stackedWidget->currentIndex();
m_ui->categoryList->setCurrentCategory(index);
}
#endif
void ReportsDialog::reject()
{
emit editFinished(true);
@ -148,6 +171,11 @@ void ReportsDialog::switchToMainView(bool previousDialogAccepted)
if (m_sender == m_browserStatPage->m_browserWidget) {
m_browserStatPage->m_browserWidget->calculateBrowserStatistics();
}
#endif
#ifdef WITH_XC_BROWSER_PASSKEYS
if (m_sender == m_passkeysPage->m_passkeysWidget) {
m_passkeysPage->m_passkeysWidget->updateEntries();
}
#endif
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2021 KeePassXC Team <team@keepassxc.org>
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -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 @@ public:
void load(const QSharedPointer<Database>& db);
void addPage(QSharedPointer<IReportsPage> page);
#ifdef WITH_XC_BROWSER_PASSKEYS
void activatePasskeysPage();
#endif
signals:
void editFinished(bool accepted);
@ -77,6 +83,9 @@ private:
const QSharedPointer<ReportsPageStatistics> m_statPage;
#ifdef WITH_XC_BROWSER
const QSharedPointer<ReportsPageBrowserStatistics> m_browserStatPage;
#endif
#ifdef WITH_XC_BROWSER_PASSKEYS
const QSharedPointer<ReportsPagePasskeys> m_passkeysPage;
#endif
QPointer<EditEntryWidget> m_editEntryWidget;
QWidget* m_sender = nullptr;

View File

@ -0,0 +1,52 @@
/*
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 or (at your option)
* version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#include "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<Database> db)
{
const auto settingsWidget = reinterpret_cast<ReportsWidgetPasskeys*>(widget);
settingsWidget->loadSettings(db);
}
void ReportsPagePasskeys::saveSettings(QWidget* widget)
{
const auto settingsWidget = reinterpret_cast<ReportsWidgetPasskeys*>(widget);
settingsWidget->saveSettings();
}

View File

@ -0,0 +1,40 @@
/*
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 or (at your option)
* version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#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<Database> db) override;
void saveSettings(QWidget* widget) override;
};
#endif // KEEPASSXC_REPORTSPAGEPASSKEYS_H

View File

@ -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<QStandardItem*>();
row << new QStandardItem(Icons::entryIconPixmap(entry), title);
@ -196,16 +199,15 @@ void ReportsWidgetBrowserStatistics::calculateBrowserStatistics()
const QScopedPointer<BrowserStatistics> 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;
}

View File

@ -10,7 +10,7 @@
<height>379</height>
</rect>
</property>
<layout class="QVBoxLayout" name="verticalLayout" stretch="0,0,0,0">
<layout class="QVBoxLayout" name="verticalLayout" stretch="0,0,0,0,0">
<property name="leftMargin">
<number>0</number>
</property>
@ -54,24 +54,27 @@
</attribute>
</widget>
</item>
<item>
<widget class="QCheckBox" name="excludeExpired">
<property name="text">
<string>Exclude expired entries from the report</string>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="showEntriesWithUrlOnlyCheckBox">
<property name="text">
<string>Show only entries which have URL set</string>
<string>Only show entries that have a URL</string>
</property>
<property name="checked">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="showConnectedOnlyCheckBox">
<widget class="QCheckBox" name="showAllowDenyCheckBox">
<property name="text">
<string>Show only entries which have browser settings in custom data</string>
<string>Only show entries that have been explicitly allowed or denied</string>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="showExpired">
<property name="text">
<string>Show expired entries</string>
</property>
</widget>
</item>
@ -91,9 +94,8 @@
</widget>
<tabstops>
<tabstop>browserStatisticsTableView</tabstop>
<tabstop>excludeExpired</tabstop>
<tabstop>showEntriesWithUrlOnlyCheckBox</tabstop>
<tabstop>showConnectedOnlyCheckBox</tabstop>
<tabstop>showAllowDenyCheckBox</tabstop>
</tabstops>
<resources/>
<connections/>

View File

@ -64,16 +64,16 @@ namespace
return m_items;
}
bool anyKnownBad() const
bool anyExcludedEntries() const
{
return m_anyKnownBad;
return m_anyExcludedEntries;
}
private:
QSharedPointer<Database> m_db;
HealthChecker m_checker;
QList<QSharedPointer<Item>> m_items;
bool m_anyKnownBad = false;
bool m_anyExcludedEntries = false;
};
class ReportSortProxyModel : public QSortFilterProxyModel
@ -121,7 +121,7 @@ Health::Health(QSharedPointer<Database> db)
// Evaluate this entry
const auto item = QSharedPointer<Item>(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<PasswordHealth> health,
Group* group,
Entry* entry,
bool knownBad)
bool excluded)
{
QString descr, tip;
QColor qualityColor;
@ -195,9 +195,12 @@ void ReportsWidgetHealthcheck::addHealthRow(QSharedPointer<PasswordHealth> healt
}
auto title = entry->title();
if (knownBad) {
if (excluded) {
title.append(tr(" (Excluded)"));
}
if (entry->isExpired()) {
title.append(tr(" (Expired)"));
}
auto row = QList<QStandardItem*>();
row << new QStandardItem(descr);
@ -215,7 +218,7 @@ void ReportsWidgetHealthcheck::addHealthRow(QSharedPointer<PasswordHealth> 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> 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)

View File

@ -56,7 +56,7 @@ public slots:
void deleteSelectedEntries();
private:
void addHealthRow(QSharedPointer<PasswordHealth>, Group*, Entry*, bool knownBad);
void addHealthRow(QSharedPointer<PasswordHealth>, Group*, Entry*, bool excluded);
QScopedPointer<Ui::ReportsWidgetHealthcheck> m_ui;

View File

@ -55,16 +55,16 @@
</widget>
</item>
<item>
<widget class="QCheckBox" name="excludeExpired">
<widget class="QCheckBox" name="showExpired">
<property name="text">
<string>Exclude expired entries from the report</string>
<string>Show expired entries</string>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="showKnownBadCheckBox">
<widget class="QCheckBox" name="showExcluded">
<property name="text">
<string>Also show entries that have been excluded from reports</string>
<string>Show entries that have been excluded from reports</string>
</property>
</widget>
</item>
@ -84,7 +84,7 @@
</widget>
<tabstops>
<tabstop>healthcheckTableView</tabstop>
<tabstop>showKnownBadCheckBox</tabstop>
<tabstop>showExcluded</tabstop>
</tabstops>
<resources/>
<connections/>

View File

@ -0,0 +1,294 @@
/*
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 or (at your option)
* version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#include "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 <QMenu>
#include <QShortcut>
#include <QSortFilterProxyModel>
#include <QStandardItemModel>
namespace
{
class PasskeyList
{
public:
struct Item
{
QPointer<Group> group;
QPointer<Entry> entry;
Item(Group* g, Entry* e)
: group(g)
, entry(e)
{
}
};
explicit PasskeyList(const QSharedPointer<Database>&);
const QList<QSharedPointer<Item>>& items() const
{
return m_items;
}
private:
QSharedPointer<Database> m_db;
QList<QSharedPointer<Item>> m_items;
};
} // namespace
PasskeyList::PasskeyList(const QSharedPointer<Database>& 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<Item>(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<QStandardItem*>();
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<Database> db)
{
m_db = std::move(db);
m_entriesUpdated = false;
m_referencesModel->clear();
m_rowToEntry.clear();
auto row = QList<QStandardItem*>();
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<PasskeyList> 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<Entry*> ReportsWidgetPasskeys::getSelectedEntries()
{
QList<Entry*> 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());
}

View File

@ -0,0 +1,76 @@
/*
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 or (at your option)
* version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#ifndef KEEPASSXC_REPORTSWIDGETPASSKEYS_H
#define KEEPASSXC_REPORTSWIDGETPASSKEYS_H
#include "gui/entry/EntryModel.h"
#include <QWidget>
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<Database> 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<Entry*> getSelectedEntries();
QScopedPointer<Ui::ReportsWidgetPasskeys> m_ui;
bool m_entriesUpdated = false;
QScopedPointer<QStandardItemModel> m_referencesModel;
QScopedPointer<QSortFilterProxyModel> m_modelProxy;
QSharedPointer<Database> m_db;
QList<QPair<Group*, Entry*>> m_rowToEntry;
};
#endif // KEEPASSXC_REPORTSWIDGETPASSKEYS_H

View File

@ -0,0 +1,102 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>ReportsWidgetPasskeys</class>
<widget class="QWidget" name="ReportsWidgetPasskeys">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>505</width>
<height>379</height>
</rect>
</property>
<layout class="QVBoxLayout" name="verticalLayout" stretch="0,0,0">
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QTableView" name="passkeysTableView">
<property name="contextMenuPolicy">
<enum>Qt::CustomContextMenu</enum>
</property>
<property name="sizeAdjustPolicy">
<enum>QAbstractScrollArea::AdjustToContents</enum>
</property>
<property name="editTriggers">
<set>QAbstractItemView::NoEditTriggers</set>
</property>
<property name="showDropIndicator" stdset="0">
<bool>false</bool>
</property>
<property name="alternatingRowColors">
<bool>true</bool>
</property>
<property name="selectionBehavior">
<enum>QAbstractItemView::SelectRows</enum>
</property>
<property name="textElideMode">
<enum>Qt::ElideRight</enum>
</property>
<property name="sortingEnabled">
<bool>true</bool>
</property>
<attribute name="horizontalHeaderStretchLastSection">
<bool>true</bool>
</attribute>
<attribute name="verticalHeaderVisible">
<bool>false</bool>
</attribute>
</widget>
</item>
<item>
<widget class="QCheckBox" name="showExpired">
<property name="text">
<string>Show expired entries</string>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QPushButton" name="importButton">
<property name="text">
<string>Import</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="exportButton">
<property name="text">
<string>Export</string>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View File

@ -25,6 +25,10 @@ QCheckBox, QRadioButton {
spacing: 10px;
}
ReportsDialog QTableView::item {
padding: 4px;
}
DatabaseWidget, DatabaseWidget #groupView, DatabaseWidget #tagView {
background-color: palette(window);
border: none;

View File

@ -1,4 +1,4 @@
# Copyright (C) 2018 KeePassXC Team <team@keepassxc.org>
# Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
# Copyright (C) 2010 Felix Geyer <debfx@fobos.de>
#
# This program is free software: you can redistribute it and/or modify
@ -233,6 +233,17 @@ endif()
if(WITH_XC_BROWSER)
add_unit_test(NAME testbrowser SOURCES TestBrowser.cpp
LIBS ${TEST_LIBRARIES})
if(WITH_XC_BROWSER_PASSKEYS)
# Prevent duplicate linking with macOS
if(APPLE)
add_unit_test(NAME testpasskeys SOURCES TestPasskeys.cpp
LIBS ${TEST_LIBRARIES})
else()
add_unit_test(NAME testpasskeys SOURCES TestPasskeys.cpp
LIBS keepassxcbrowser ${TEST_LIBRARIES})
endif()
endif()
endif()
add_unit_test(NAME testcli SOURCES TestCli.cpp

471
tests/TestPasskeys.cpp Normal file
View File

@ -0,0 +1,471 @@
/*
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 or (at your option)
* version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#include "TestPasskeys.h"
#include "browser/BrowserCbor.h"
#include "browser/BrowserMessageBuilder.h"
#include "browser/BrowserService.h"
#include "crypto/Crypto.h"
#include <QJsonArray>
#include <QJsonDocument>
#include <QTest>
#include <botan/sodium.h>
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<const char*>(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<unsigned char>(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);
}

47
tests/TestPasskeys.h Normal file
View File

@ -0,0 +1,47 @@
/*
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 or (at your option)
* version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#ifndef KEEPASSXC_TESTPASSKEYS_H
#define KEEPASSXC_TESTPASSKEYS_H
#include <QObject>
#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