From 8f45431ecb0364f00c0d332ae8e4f5b6b07ec678 Mon Sep 17 00:00:00 2001 From: varjolintu Date: Sat, 14 Oct 2023 16:18:27 +0300 Subject: [PATCH 1/8] Create new UrlTools class --- src/CMakeLists.txt | 3 +- src/browser/BrowserService.cpp | 70 +------------ src/browser/BrowserService.h | 4 - src/core/Tools.cpp | 31 +----- src/core/Tools.h | 3 +- src/core/UrlTools.cpp | 173 ++++++++++++++++++++++++++++++++ src/core/UrlTools.h | 56 +++++++++++ src/gui/IconDownloader.cpp | 38 +------ src/gui/URLEdit.cpp | 5 +- src/gui/entry/EntryURLModel.cpp | 8 +- tests/CMakeLists.txt | 2 + tests/TestBrowser.cpp | 84 ---------------- tests/TestBrowser.h | 4 - tests/TestUrlTools.cpp | 129 ++++++++++++++++++++++++ tests/TestUrlTools.h | 41 ++++++++ 15 files changed, 418 insertions(+), 233 deletions(-) create mode 100644 src/core/UrlTools.cpp create mode 100644 src/core/UrlTools.h create mode 100644 tests/TestUrlTools.cpp create mode 100644 tests/TestUrlTools.h diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index b8099eed59..056df57863 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1,4 +1,4 @@ -# Copyright (C) 2018 KeePassXC Team +# Copyright (C) 2023 KeePassXC Team # Copyright (C) 2010 Felix Geyer # # This program is free software: you can redistribute it and/or modify @@ -60,6 +60,7 @@ set(keepassx_SOURCES core/TimeInfo.cpp core/Tools.cpp core/Translator.cpp + core/UrlTools.cpp cli/Utils.cpp cli/TextStream.cpp crypto/Crypto.cpp diff --git a/src/browser/BrowserService.cpp b/src/browser/BrowserService.cpp index b412409a59..1ebf79f3ff 100644 --- a/src/browser/BrowserService.cpp +++ b/src/browser/BrowserService.cpp @@ -25,6 +25,7 @@ #include "BrowserMessageBuilder.h" #include "BrowserSettings.h" #include "core/Tools.h" +#include "core/UrlTools.h" #include "gui/MainWindow.h" #include "gui/MessageBox.h" #include "gui/osutils/OSUtils.h" @@ -544,33 +545,6 @@ bool BrowserService::isPasswordGeneratorRequested() const return m_passwordGeneratorRequested; } -// Returns true if URLs are identical. Paths with "/" are removed during comparison. -// URLs without scheme reverts to https. -// Special handling is needed because QUrl::matches() with QUrl::StripTrailingSlash does not strip "/" paths. -bool BrowserService::isUrlIdentical(const QString& first, const QString& second) const -{ - auto trimUrl = [](QString url) { - url = url.trimmed(); - if (url.endsWith("/")) { - url.remove(url.length() - 1, 1); - } - - return url; - }; - - if (first.isEmpty() || second.isEmpty()) { - return false; - } - - const auto firstUrl = trimUrl(first); - const auto secondUrl = trimUrl(second); - if (firstUrl == secondUrl) { - return true; - } - - return QUrl(firstUrl).matches(QUrl(secondUrl), QUrl::StripTrailingSlash); -} - QString BrowserService::storeKey(const QString& key) { auto db = getDatabase(); @@ -1080,18 +1054,6 @@ int BrowserService::sortPriority(const QStringList& urls, const QString& siteUrl return *std::max_element(priorityList.begin(), priorityList.end()); } -bool BrowserService::schemeFound(const QString& url) -{ - QUrl address(url); - return !address.scheme().isEmpty(); -} - -bool BrowserService::isIpAddress(const QString& host) const -{ - QHostAddress address(host); - return address.protocol() == QAbstractSocket::IPv4Protocol || address.protocol() == QAbstractSocket::IPv6Protocol; -} - bool BrowserService::removeFirstDomain(QString& hostname) { int pos = hostname.indexOf("."); @@ -1187,7 +1149,7 @@ bool BrowserService::handleURL(const QString& entryUrl, } // Match the base domain - if (getTopLevelDomainFromUrl(siteQUrl.host()) != getTopLevelDomainFromUrl(entryQUrl.host())) { + if (urlTools()->getBaseDomainFromUrl(siteQUrl.host()) != urlTools()->getBaseDomainFromUrl(entryQUrl.host())) { return false; } @@ -1197,34 +1159,6 @@ bool BrowserService::handleURL(const QString& entryUrl, } return false; -}; - -/** - * Gets the base domain of URL. - * - * Returns the base domain, e.g. https://another.example.co.uk -> example.co.uk - */ -QString BrowserService::getTopLevelDomainFromUrl(const QString& url) const -{ - QUrl qurl = QUrl::fromUserInput(url); - QString host = qurl.host(); - - // If the hostname is an IP address, return it directly - if (isIpAddress(host)) { - return host; - } - - if (host.isEmpty() || !host.contains(qurl.topLevelDomain())) { - return {}; - } - - // Remove the top level domain part from the hostname, e.g. https://another.example.co.uk -> https://another.example - host.chop(qurl.topLevelDomain().length()); - // Split the URL and select the last part, e.g. https://another.example -> example - QString baseDomain = host.split('.').last(); - // Append the top level domain back to the URL, e.g. example -> example.co.uk - baseDomain.append(qurl.topLevelDomain()); - return baseDomain; } QSharedPointer BrowserService::getDatabase() diff --git a/src/browser/BrowserService.h b/src/browser/BrowserService.h index 46bffef014..ca3579e024 100644 --- a/src/browser/BrowserService.h +++ b/src/browser/BrowserService.h @@ -82,7 +82,6 @@ class BrowserService : public QObject QString getCurrentTotp(const QString& uuid); void showPasswordGenerator(const KeyPairMessage& keyPairMessage); bool isPasswordGeneratorRequested() const; - bool isUrlIdentical(const QString& first, const QString& second) const; void addEntry(const EntryParameters& entryParameters, const QString& group, @@ -146,7 +145,6 @@ private slots: Group* getDefaultEntryGroup(const QSharedPointer& selectedDb = {}); int sortPriority(const QStringList& urls, const QString& siteUrl, const QString& formUrl); bool schemeFound(const QString& url); - bool isIpAddress(const QString& host) const; bool removeFirstDomain(QString& hostname); bool shouldIncludeEntry(Entry* entry, const QString& url, const QString& submitUrl, const bool omitWwwSubdomain = false); @@ -154,8 +152,6 @@ private slots: const QString& siteUrl, const QString& formUrl, const bool omitWwwSubdomain = false); - QString getTopLevelDomainFromUrl(const QString& url) const; - QString baseDomain(const QString& hostname) const; QSharedPointer getDatabase(); QSharedPointer selectedDatabase(); QString getDatabaseRootUuid(); diff --git a/src/core/Tools.cpp b/src/core/Tools.cpp index 6577971169..824f9ff924 100644 --- a/src/core/Tools.cpp +++ b/src/core/Tools.cpp @@ -5,7 +5,7 @@ * Copyright (C) 2020 Klarälvdalens Datakonsult AB, a KDAB Group company, info@kdab.com, * author Giuseppe D'Angelo * Copyright (C) 2021 The Qt Company Ltd. - * Copyright (C) 2021 KeePassXC Team + * Copyright (C) 2023 KeePassXC Team * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -271,35 +271,6 @@ namespace Tools } } - bool checkUrlValid(const QString& urlField) - { - if (urlField.isEmpty() || urlField.startsWith("cmd://", Qt::CaseInsensitive) - || urlField.startsWith("kdbx://", Qt::CaseInsensitive) - || urlField.startsWith("{REF:A", Qt::CaseInsensitive)) { - return true; - } - - QUrl url; - if (urlField.contains("://")) { - url = urlField; - } else { - url = QUrl::fromUserInput(urlField); - } - - if (url.scheme() != "file" && url.host().isEmpty()) { - return false; - } - - // Check for illegal characters. Adds also the wildcard * to the list - QRegularExpression re("[<>\\^`{|}\\*]"); - auto match = re.match(urlField); - if (match.hasMatch()) { - return false; - } - - return true; - } - /**************************************************************************** * * Copyright (C) 2020 Giuseppe D'Angelo . diff --git a/src/core/Tools.h b/src/core/Tools.h index a8094d0a30..3df2ca0085 100644 --- a/src/core/Tools.h +++ b/src/core/Tools.h @@ -1,6 +1,6 @@ /* * Copyright (C) 2012 Felix Geyer - * Copyright (C) 2021 KeePassXC Team + * Copyright (C) 2023 KeePassXC Team * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -38,7 +38,6 @@ namespace Tools bool isBase64(const QByteArray& ba); void sleep(int ms); void wait(int ms); - bool checkUrlValid(const QString& urlField); QString uuidToHex(const QUuid& uuid); QUuid hexToUuid(const QString& uuid); bool isValidUuid(const QString& uuidStr); diff --git a/src/core/UrlTools.cpp b/src/core/UrlTools.cpp new file mode 100644 index 0000000000..bd6db52718 --- /dev/null +++ b/src/core/UrlTools.cpp @@ -0,0 +1,173 @@ +/* + * Copyright (C) 2023 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "UrlTools.h" +#ifdef WITH_XC_NETWORKING +#include +#include +#include +#endif +#include +#include + +Q_GLOBAL_STATIC(UrlTools, s_urlTools) + +UrlTools* UrlTools::instance() +{ + return s_urlTools; +} + +QUrl UrlTools::convertVariantToUrl(const QVariant& var) const +{ + QUrl url; + if (var.canConvert()) { + url = var.toUrl(); + } + return url; +} + +#ifdef WITH_XC_NETWORKING +QUrl UrlTools::getRedirectTarget(QNetworkReply* reply) const +{ + QVariant var = reply->attribute(QNetworkRequest::RedirectionTargetAttribute); + QUrl url = convertVariantToUrl(var); + return url; +} + +/** + * Gets the base domain of URL or hostname. + * + * Returns the base domain, e.g. https://another.example.co.uk -> example.co.uk + * Up-to-date list can be found: https://publicsuffix.org/list/public_suffix_list.dat + */ +QString UrlTools::getBaseDomainFromUrl(const QString& url) const +{ + auto qUrl = QUrl::fromUserInput(url); + + auto host = qUrl.host(); + if (isIpAddress(host)) { + return host; + } + + const auto tld = getTopLevelDomainFromUrl(qUrl.toString()); + if (tld.isEmpty() || tld.length() + 1 >= host.length()) { + return host; + } + + // Remove the top level domain part from the hostname, e.g. https://another.example.co.uk -> https://another.example + host.chop(tld.length() + 1); + // Split the URL and select the last part, e.g. https://another.example -> example + QString baseDomain = host.split('.').last(); + // Append the top level domain back to the URL, e.g. example -> example.co.uk + baseDomain.append(QString(".%1").arg(tld)); + + return baseDomain; +} + +/** + * Gets the top level domain from URL. + * + * Returns the TLD e.g. https://another.example.co.uk -> co.uk + */ +QString UrlTools::getTopLevelDomainFromUrl(const QString& url) const +{ + auto host = QUrl::fromUserInput(url).host(); + if (isIpAddress(host)) { + return host; + } + + const auto numberOfDomainParts = host.split('.').length(); + static const auto dummy = QByteArrayLiteral(""); + + // Only loop the amount of different parts found + for (auto i = 0; i < numberOfDomainParts; ++i) { + // Cut the first part from host + host = host.mid(host.indexOf('.') + 1); + + QNetworkCookie cookie(dummy, dummy); + cookie.setDomain(host); + + // Check if dummy cookie's domain/TLD matches with public suffix list + if (!QNetworkCookieJar{}.setCookiesFromUrl(QList{cookie}, url)) { + return host; + } + } + + return host; +} + +bool UrlTools::isIpAddress(const QString& host) const +{ + QHostAddress address(host); + return address.protocol() == QAbstractSocket::IPv4Protocol || address.protocol() == QAbstractSocket::IPv6Protocol; +} +#endif + +// Returns true if URLs are identical. Paths with "/" are removed during comparison. +// URLs without scheme reverts to https. +// Special handling is needed because QUrl::matches() with QUrl::StripTrailingSlash does not strip "/" paths. +bool UrlTools::isUrlIdentical(const QString& first, const QString& second) const +{ + auto trimUrl = [](QString url) { + url = url.trimmed(); + if (url.endsWith("/")) { + url.remove(url.length() - 1, 1); + } + + return url; + }; + + if (first.isEmpty() || second.isEmpty()) { + return false; + } + + const auto firstUrl = trimUrl(first); + const auto secondUrl = trimUrl(second); + if (firstUrl == secondUrl) { + return true; + } + + return QUrl(firstUrl).matches(QUrl(secondUrl), QUrl::StripTrailingSlash); +} + +bool UrlTools::isUrlValid(const QString& urlField) const +{ + if (urlField.isEmpty() || urlField.startsWith("cmd://", Qt::CaseInsensitive) + || urlField.startsWith("kdbx://", Qt::CaseInsensitive) || urlField.startsWith("{REF:A", Qt::CaseInsensitive)) { + return true; + } + + QUrl url; + if (urlField.contains("://")) { + url = urlField; + } else { + url = QUrl::fromUserInput(urlField); + } + + if (url.scheme() != "file" && url.host().isEmpty()) { + return false; + } + + // Check for illegal characters. Adds also the wildcard * to the list + QRegularExpression re("[<>\\^`{|}\\*]"); + auto match = re.match(urlField); + if (match.hasMatch()) { + return false; + } + + return true; +} diff --git a/src/core/UrlTools.h b/src/core/UrlTools.h new file mode 100644 index 0000000000..c86152d038 --- /dev/null +++ b/src/core/UrlTools.h @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2023 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEEPASSXC_URLTOOLS_H +#define KEEPASSXC_URLTOOLS_H + +#include "config-keepassx.h" +#include +#include +#include +#include + +class UrlTools : public QObject +{ + Q_OBJECT + +public: + explicit UrlTools() = default; + static UrlTools* instance(); + +#ifdef WITH_XC_NETWORKING + QUrl getRedirectTarget(QNetworkReply* reply) const; + QString getBaseDomainFromUrl(const QString& url) const; + QString getTopLevelDomainFromUrl(const QString& url) const; + bool isIpAddress(const QString& host) const; +#endif + bool isUrlIdentical(const QString& first, const QString& second) const; + bool isUrlValid(const QString& urlField) const; + +private: + QUrl convertVariantToUrl(const QVariant& var) const; + +private: + Q_DISABLE_COPY(UrlTools); +}; + +static inline UrlTools* urlTools() +{ + return UrlTools::instance(); +} + +#endif // KEEPASSXC_URLTOOLS_H diff --git a/src/gui/IconDownloader.cpp b/src/gui/IconDownloader.cpp index 7e3fff0aec..1adb269229 100644 --- a/src/gui/IconDownloader.cpp +++ b/src/gui/IconDownloader.cpp @@ -1,5 +1,5 @@ /* - * Copyright (C) 2019 KeePassXC Team + * Copyright (C) 2023 KeePassXC Team * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,6 +18,7 @@ #include "IconDownloader.h" #include "core/Config.h" #include "core/NetworkManager.h" +#include "core/UrlTools.h" #include #include @@ -40,37 +41,6 @@ IconDownloader::~IconDownloader() abortDownload(); } -namespace -{ - // Try to get the 2nd level domain of the host part of a QUrl. For example, - // "foo.bar.example.com" would become "example.com", and "foo.bar.example.co.uk" - // would become "example.co.uk". - QString getSecondLevelDomain(const QUrl& url) - { - QString fqdn = url.host(); - fqdn.truncate(fqdn.length() - url.topLevelDomain().length()); - QStringList parts = fqdn.split('.'); - QString newdom = parts.takeLast() + url.topLevelDomain(); - return newdom; - } - - QUrl convertVariantToUrl(const QVariant& var) - { - QUrl url; - if (var.canConvert()) { - url = var.toUrl(); - } - return url; - } - - QUrl getRedirectTarget(QNetworkReply* reply) - { - QVariant var = reply->attribute(QNetworkRequest::RedirectionTargetAttribute); - QUrl url = convertVariantToUrl(var); - return url; - } -} // namespace - void IconDownloader::setUrl(const QString& entryUrl) { m_url = entryUrl; @@ -114,7 +84,7 @@ void IconDownloader::setUrl(const QString& entryUrl) // Determine the second-level domain, if available QString secondLevelDomain; if (!hostIsIp) { - secondLevelDomain = getSecondLevelDomain(url); + secondLevelDomain = urlTools()->getBaseDomainFromUrl(url.toString()); } // Start with the "fallback" url (if enabled) to try to get the best favicon @@ -202,7 +172,7 @@ void IconDownloader::fetchFinished() QString url = m_url; bool error = (m_reply->error() != QNetworkReply::NoError); - QUrl redirectTarget = getRedirectTarget(m_reply); + QUrl redirectTarget = urlTools()->getRedirectTarget(m_reply); m_reply->deleteLater(); m_reply = nullptr; diff --git a/src/gui/URLEdit.cpp b/src/gui/URLEdit.cpp index d249ddd850..f5fbbb24be 100644 --- a/src/gui/URLEdit.cpp +++ b/src/gui/URLEdit.cpp @@ -1,6 +1,6 @@ /* + * Copyright (C) 2023 KeePassXC Team * Copyright (C) 2014 Felix Geyer - * Copyright (C) 2020 KeePassXC Team * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -19,6 +19,7 @@ #include "URLEdit.h" #include "core/Tools.h" +#include "core/UrlTools.h" #include "gui/Icons.h" #include "gui/styles/StateColorPalette.h" @@ -44,7 +45,7 @@ void URLEdit::updateStylesheet() { const QString stylesheetTemplate("QLineEdit { background: %1; }"); - if (!Tools::checkUrlValid(text())) { + if (!urlTools()->isUrlValid(text())) { StateColorPalette statePalette; QColor color = statePalette.color(StateColorPalette::ColorRole::Error); setStyleSheet(stylesheetTemplate.arg(color.name())); diff --git a/src/gui/entry/EntryURLModel.cpp b/src/gui/entry/EntryURLModel.cpp index 55d8dd51cd..9a4340f5cd 100644 --- a/src/gui/entry/EntryURLModel.cpp +++ b/src/gui/entry/EntryURLModel.cpp @@ -20,7 +20,7 @@ #include "browser/BrowserService.h" #include "core/EntryAttributes.h" -#include "core/Tools.h" +#include "core/UrlTools.h" #include "gui/Icons.h" #include "gui/styles/StateColorPalette.h" @@ -67,14 +67,14 @@ QVariant EntryURLModel::data(const QModelIndex& index, int role) const } const auto value = m_entryAttributes->value(key); - const auto urlValid = Tools::checkUrlValid(value); + const auto urlValid = urlTools()->isUrlValid(value); // Check for duplicate URLs in the attribute list. Excludes the current key/value from the comparison. auto customAttributeKeys = m_entryAttributes->customKeys().filter(BrowserService::ADDITIONAL_URL); customAttributeKeys.removeOne(key); - const auto duplicateUrl = m_entryAttributes->values(customAttributeKeys).contains(value) - || browserService()->isUrlIdentical(value, m_entryUrl); + const auto duplicateUrl = + m_entryAttributes->values(customAttributeKeys).contains(value) || urlTools()->isUrlIdentical(value, m_entryUrl); if (role == Qt::BackgroundRole && (!urlValid || duplicateUrl)) { StateColorPalette statePalette; return statePalette.color(StateColorPalette::ColorRole::Error); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index db82da1639..1abe869a41 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -150,6 +150,8 @@ if(WITH_XC_NETWORKING) LIBS ${TEST_LIBRARIES}) add_unit_test(NAME testicondownloader SOURCES TestIconDownloader.cpp LIBS ${TEST_LIBRARIES}) + + add_unit_test(NAME testurltools SOURCES TestUrlTools.cpp LIBS ${TEST_LIBRARIES}) endif() if(WITH_XC_AUTOTYPE) diff --git a/tests/TestBrowser.cpp b/tests/TestBrowser.cpp index d7345537d6..aa084921ed 100644 --- a/tests/TestBrowser.cpp +++ b/tests/TestBrowser.cpp @@ -144,54 +144,6 @@ void TestBrowser::testBuildResponse() QCOMPARE(firstArr["test"].toBool(), true); } -/** - * Tests for BrowserService - */ -void TestBrowser::testTopLevelDomain() -{ - QString url1 = "https://another.example.co.uk"; - QString url2 = "https://www.example.com"; - QString url3 = "http://test.net"; - QString url4 = "http://so.many.subdomains.co.jp"; - QString url5 = "https://192.168.0.1"; - QString url6 = "https://192.168.0.1:8000"; - - QString res1 = m_browserService->getTopLevelDomainFromUrl(url1); - QString res2 = m_browserService->getTopLevelDomainFromUrl(url2); - QString res3 = m_browserService->getTopLevelDomainFromUrl(url3); - QString res4 = m_browserService->getTopLevelDomainFromUrl(url4); - QString res5 = m_browserService->getTopLevelDomainFromUrl(url5); - QString res6 = m_browserService->getTopLevelDomainFromUrl(url6); - - QCOMPARE(res1, QString("example.co.uk")); - QCOMPARE(res2, QString("example.com")); - QCOMPARE(res3, QString("test.net")); - QCOMPARE(res4, QString("subdomains.co.jp")); - QCOMPARE(res5, QString("192.168.0.1")); - QCOMPARE(res6, QString("192.168.0.1")); -} - -void TestBrowser::testIsIpAddress() -{ - auto host1 = "example.com"; // Not valid - auto host2 = "192.168.0.1"; - auto host3 = "278.21.2.0"; // Not valid - auto host4 = "2001:0db8:85a3:0000:0000:8a2e:0370:7334"; - auto host5 = "2001:db8:0:1:1:1:1:1"; - auto host6 = "fe80::1ff:fe23:4567:890a"; - auto host7 = "2001:20::1"; - auto host8 = "2001:0db8:85y3:0000:0000:8a2e:0370:7334"; // Not valid - - QVERIFY(!m_browserService->isIpAddress(host1)); - QVERIFY(m_browserService->isIpAddress(host2)); - QVERIFY(!m_browserService->isIpAddress(host3)); - QVERIFY(m_browserService->isIpAddress(host4)); - QVERIFY(m_browserService->isIpAddress(host5)); - QVERIFY(m_browserService->isIpAddress(host6)); - QVERIFY(m_browserService->isIpAddress(host7)); - QVERIFY(!m_browserService->isIpAddress(host8)); -} - void TestBrowser::testSortPriority() { QFETCH(QString, entryUrl); @@ -583,26 +535,6 @@ QList TestBrowser::createEntries(QStringList& urls, Group* root) const return entries; } -void TestBrowser::testValidURLs() -{ - QHash urls; - urls["https://github.com/login"] = true; - urls["https:///github.com/"] = false; - urls["http://github.com/**//*"] = false; - urls["http://*.github.com/login"] = false; - urls["//github.com"] = true; - urls["github.com/{}<>"] = false; - urls["http:/example.com"] = false; - urls["cmd://C:/Toolchains/msys2/usr/bin/mintty \"ssh jon@192.168.0.1:22\""] = true; - urls["file:///Users/testUser/Code/test.html"] = true; - urls["{REF:A@I:46C9B1FFBD4ABC4BBB260C6190BAD20C} "] = true; - - QHashIterator i(urls); - while (i.hasNext()) { - i.next(); - QCOMPARE(Tools::checkUrlValid(i.key()), i.value()); - } -} void TestBrowser::testBestMatchingCredentials() { @@ -741,19 +673,3 @@ void TestBrowser::testBestMatchingWithAdditionalURLs() QCOMPARE(sorted.length(), 1); QCOMPARE(sorted[0]->url(), urls[0]); } - -void TestBrowser::testIsUrlIdentical() -{ - QVERIFY(browserService()->isUrlIdentical("https://example.com", "https://example.com")); - QVERIFY(browserService()->isUrlIdentical("https://example.com", " https://example.com ")); - QVERIFY(!browserService()->isUrlIdentical("https://example.com", "https://example2.com")); - QVERIFY(!browserService()->isUrlIdentical("https://example.com/", "https://example.com/#login")); - QVERIFY(browserService()->isUrlIdentical("https://example.com", "https://example.com/")); - QVERIFY(browserService()->isUrlIdentical("https://example.com/", "https://example.com")); - QVERIFY(browserService()->isUrlIdentical("https://example.com/ ", " https://example.com")); - QVERIFY(!browserService()->isUrlIdentical("https://example.com/", " example.com")); - QVERIFY(browserService()->isUrlIdentical("https://example.com/path/to/nowhere", - "https://example.com/path/to/nowhere/")); - QVERIFY(!browserService()->isUrlIdentical("https://example.com/", "://example.com/")); - QVERIFY(browserService()->isUrlIdentical("ftp://127.0.0.1/", "ftp://127.0.0.1")); -} diff --git a/tests/TestBrowser.h b/tests/TestBrowser.h index ed8146b574..48ac3b1cd5 100644 --- a/tests/TestBrowser.h +++ b/tests/TestBrowser.h @@ -37,8 +37,6 @@ private slots: void testGetBase64FromKey(); void testIncrementNonce(); void testBuildResponse(); - void testTopLevelDomain(); - void testIsIpAddress(); void testSortPriority(); void testSortPriority_data(); void testSearchEntries(); @@ -49,10 +47,8 @@ private slots: void testSearchEntriesWithAdditionalURLs(); void testInvalidEntries(); void testSubdomainsAndPaths(); - void testValidURLs(); void testBestMatchingCredentials(); void testBestMatchingWithAdditionalURLs(); - void testIsUrlIdentical(); private: QList createEntries(QStringList& urls, Group* root) const; diff --git a/tests/TestUrlTools.cpp b/tests/TestUrlTools.cpp new file mode 100644 index 0000000000..0e3ef844ee --- /dev/null +++ b/tests/TestUrlTools.cpp @@ -0,0 +1,129 @@ +/* + * Copyright (C) 2023 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "TestUrlTools.h" +#include + +QTEST_GUILESS_MAIN(TestUrlTools) + +void TestUrlTools::initTestCase() +{ + m_urlTools = urlTools(); +} + +void TestUrlTools::init() +{ +} + +void TestUrlTools::testTopLevelDomain() +{ + // Create list of URLs and expected TLD responses + QList> tldUrls{ + {QString("https://another.example.co.uk"), QString("co.uk")}, + {QString("https://www.example.com"), QString("com")}, + {QString("https://github.com"), QString("com")}, + {QString("http://test.net"), QString("net")}, + {QString("http://so.many.subdomains.co.jp"), QString("co.jp")}, + {QString("https://192.168.0.1"), QString("192.168.0.1")}, + {QString("https://192.168.0.1:8000"), QString("192.168.0.1")}, + {QString("https://www.nic.ar"), QString("ar")}, + {QString("https://no.no.no"), QString("no")}, + {QString("https://www.blogspot.com.ar"), QString("blogspot.com.ar")}, // blogspot.com.ar is a TLD + {QString("https://jap.an.ide.kyoto.jp"), QString("ide.kyoto.jp")}, // ide.kyoto.jp is a TLD + {QString("ar"), QString("ar")}, + }; + + for (const auto& u : tldUrls) { + QCOMPARE(urlTools()->getTopLevelDomainFromUrl(u.first), u.second); + } + + // Create list of URLs and expected base URL responses + QList> baseUrls{ + {QString("https://another.example.co.uk"), QString("example.co.uk")}, + {QString("https://www.example.com"), QString("example.com")}, + {QString("http://test.net"), QString("test.net")}, + {QString("http://so.many.subdomains.co.jp"), QString("subdomains.co.jp")}, + {QString("https://192.168.0.1"), QString("192.168.0.1")}, + {QString("https://192.168.0.1:8000"), QString("192.168.0.1")}, + {QString("https://www.nic.ar"), QString("nic.ar")}, + {QString("https://www.blogspot.com.ar"), QString("www.blogspot.com.ar")}, // blogspot.com.ar is a TLD + {QString("https://www.arpa"), QString("www.arpa")}, + {QString("https://jap.an.ide.kyoto.jp"), QString("an.ide.kyoto.jp")}, // ide.kyoto.jp is a TLD + {QString("https://kobe.jp"), QString("kobe.jp")}, + }; + + for (const auto& u : baseUrls) { + QCOMPARE(urlTools()->getBaseDomainFromUrl(u.first), u.second); + } +} + +void TestUrlTools::testIsIpAddress() +{ + auto host1 = "example.com"; // Not valid + auto host2 = "192.168.0.1"; + auto host3 = "278.21.2.0"; // Not valid + auto host4 = "2001:0db8:85a3:0000:0000:8a2e:0370:7334"; + auto host5 = "2001:db8:0:1:1:1:1:1"; + auto host6 = "fe80::1ff:fe23:4567:890a"; + auto host7 = "2001:20::1"; + auto host8 = "2001:0db8:85y3:0000:0000:8a2e:0370:7334"; // Not valid + + QVERIFY(!urlTools()->isIpAddress(host1)); + QVERIFY(urlTools()->isIpAddress(host2)); + QVERIFY(!urlTools()->isIpAddress(host3)); + QVERIFY(urlTools()->isIpAddress(host4)); + QVERIFY(urlTools()->isIpAddress(host5)); + QVERIFY(urlTools()->isIpAddress(host6)); + QVERIFY(urlTools()->isIpAddress(host7)); + QVERIFY(!urlTools()->isIpAddress(host8)); +} + +void TestUrlTools::testIsUrlIdentical() +{ + QVERIFY(urlTools()->isUrlIdentical("https://example.com", "https://example.com")); + QVERIFY(urlTools()->isUrlIdentical("https://example.com", " https://example.com ")); + QVERIFY(!urlTools()->isUrlIdentical("https://example.com", "https://example2.com")); + QVERIFY(!urlTools()->isUrlIdentical("https://example.com/", "https://example.com/#login")); + QVERIFY(urlTools()->isUrlIdentical("https://example.com", "https://example.com/")); + QVERIFY(urlTools()->isUrlIdentical("https://example.com/", "https://example.com")); + QVERIFY(urlTools()->isUrlIdentical("https://example.com/ ", " https://example.com")); + QVERIFY(!urlTools()->isUrlIdentical("https://example.com/", " example.com")); + QVERIFY(urlTools()->isUrlIdentical("https://example.com/path/to/nowhere", "https://example.com/path/to/nowhere/")); + QVERIFY(!urlTools()->isUrlIdentical("https://example.com/", "://example.com/")); + QVERIFY(urlTools()->isUrlIdentical("ftp://127.0.0.1/", "ftp://127.0.0.1")); +} + +void TestUrlTools::testIsUrlValid() +{ + QHash urls; + urls["https://github.com/login"] = true; + urls["https:///github.com/"] = false; + urls["http://github.com/**//*"] = false; + urls["http://*.github.com/login"] = false; + urls["//github.com"] = true; + urls["github.com/{}<>"] = false; + urls["http:/example.com"] = false; + urls["cmd://C:/Toolchains/msys2/usr/bin/mintty \"ssh jon@192.168.0.1:22\""] = true; + urls["file:///Users/testUser/Code/test.html"] = true; + urls["{REF:A@I:46C9B1FFBD4ABC4BBB260C6190BAD20C} "] = true; + + QHashIterator i(urls); + while (i.hasNext()) { + i.next(); + QCOMPARE(urlTools()->isUrlValid(i.key()), i.value()); + } +} diff --git a/tests/TestUrlTools.h b/tests/TestUrlTools.h new file mode 100644 index 0000000000..d26e470406 --- /dev/null +++ b/tests/TestUrlTools.h @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2023 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEEPASSXC_TESTURLTOOLS_H +#define KEEPASSXC_TESTURLTOOLS_H + +#include "core/UrlTools.h" +#include +#include + +class TestUrlTools : public QObject +{ + Q_OBJECT + +private slots: + void initTestCase(); + void init(); + + void testTopLevelDomain(); + void testIsIpAddress(); + void testIsUrlIdentical(); + void testIsUrlValid(); + +private: + QPointer m_urlTools; +}; +#endif // KEEPASSXC_TESTURLTOOLS_H From 1a81f79df790bdf5d7b35f35cc76d088a542e449 Mon Sep 17 00:00:00 2001 From: varjolintu Date: Mon, 18 Sep 2023 10:20:58 +0300 Subject: [PATCH 2/8] Fix raising Update Entry messagebox --- src/browser/BrowserService.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/browser/BrowserService.cpp b/src/browser/BrowserService.cpp index 1ebf79f3ff..406276182d 100644 --- a/src/browser/BrowserService.cpp +++ b/src/browser/BrowserService.cpp @@ -701,8 +701,7 @@ bool BrowserService::updateEntry(const EntryParameters& entryParameters, const Q tr("Do you want to update the information in %1 - %2?") .arg(QUrl(entryParameters.siteUrl).host(), username), MessageBox::Save | MessageBox::Cancel, - MessageBox::Cancel, - MessageBox::Raise); + MessageBox::Cancel); } if (browserSettings()->alwaysAllowUpdate() || dialogResult == MessageBox::Save) { From ddd2fcecea9701bfbeb5400f1df7ba0aeb4867bf Mon Sep 17 00:00:00 2001 From: Jonathan White Date: Wed, 16 Aug 2023 07:38:16 -0400 Subject: [PATCH 3/8] Prevent scrollbars on entry drag/drop * Fixes #9746 --- src/gui/entry/EntryView.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/gui/entry/EntryView.cpp b/src/gui/entry/EntryView.cpp index c587f7c1ea..7c5768ef4b 100644 --- a/src/gui/entry/EntryView.cpp +++ b/src/gui/entry/EntryView.cpp @@ -559,6 +559,8 @@ void EntryView::startDrag(Qt::DropActions supportedActions) listWidget.addItem(item); } + listWidget.setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + listWidget.setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); listWidget.setStyleSheet("QListWidget { background-color: palette(highlight); border: 1px solid palette(dark); " "padding: 4px; color: palette(highlighted-text); }"); auto width = listWidget.sizeHintForColumn(0) + 2 * listWidget.frameWidth(); From f93adaa854b859dc0bda4ad3422f6f98b269f744 Mon Sep 17 00:00:00 2001 From: Thomas Hobson Date: Mon, 4 Sep 2023 23:35:06 -0400 Subject: [PATCH 4/8] Add Polkit Quick Unlock Support Closes #5991 Closes #3337 - Support fingerprint readers on Linux Polkit allows for authentication of many means, including fingerprint scanning. Furthermore, a common interface for Quick Unlocking has been implemented, and has been replaced throughout to make implementing other quick unlock strategies easier. Refactor QuickUnlock to use UUID stored in headers. This is a new feature using the KDBX 4 standard to store a randomly generated UUID in the public headers of the database. This enables identification of KDBX file without relying on path or filename and will eventually support persistent Quick Unlock. --- .github/workflows/codeql.yml | 2 +- cmake/CLangFormat.cmake | 2 +- share/CMakeLists.txt | 5 + share/linux/org.keepassxc.KeePassXC.policy.in | 18 ++ share/translations/keepassxc_en.ts | 85 ++++-- src/CMakeLists.txt | 29 +- src/core/Database.cpp | 13 + src/core/Database.h | 1 + src/format/KdbxReader.cpp | 9 + src/gui/ApplicationSettingsWidget.cpp | 16 +- src/gui/DatabaseOpenWidget.cpp | 87 +++--- .../DatabaseSettingsWidgetDatabaseKey.cpp | 14 +- src/gui/osutils/nixutils/NixUtils.cpp | 27 ++ src/gui/osutils/nixutils/NixUtils.h | 2 + src/gui/reports/ReportsDialog.cpp | 3 - src/quickunlock/Polkit.cpp | 247 ++++++++++++++++++ src/quickunlock/Polkit.h | 50 ++++ src/quickunlock/PolkitDbusTypes.cpp | 45 ++++ src/quickunlock/PolkitDbusTypes.h | 36 +++ src/quickunlock/QuickUnlockInterface.cpp | 81 ++++++ src/quickunlock/QuickUnlockInterface.h | 58 ++++ src/quickunlock/TouchID.h | 47 ++++ src/{touchid => quickunlock}/TouchID.mm | 80 +++--- .../WindowsHello.cpp | 50 ++-- src/{winhello => quickunlock}/WindowsHello.h | 37 +-- .../org.freedesktop.PolicyKit1.Authority.xml | 16 ++ src/touchid/TouchID.h | 39 --- 27 files changed, 839 insertions(+), 260 deletions(-) create mode 100644 share/linux/org.keepassxc.KeePassXC.policy.in create mode 100644 src/quickunlock/Polkit.cpp create mode 100644 src/quickunlock/Polkit.h create mode 100644 src/quickunlock/PolkitDbusTypes.cpp create mode 100644 src/quickunlock/PolkitDbusTypes.h create mode 100644 src/quickunlock/QuickUnlockInterface.cpp create mode 100644 src/quickunlock/QuickUnlockInterface.h create mode 100644 src/quickunlock/TouchID.h rename src/{touchid => quickunlock}/TouchID.mm (85%) rename src/{winhello => quickunlock}/WindowsHello.cpp (82%) rename src/{winhello => quickunlock}/WindowsHello.h (57%) create mode 100644 src/quickunlock/dbus/org.freedesktop.PolicyKit1.Authority.xml delete mode 100644 src/touchid/TouchID.h diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 98b8e25343..24eb2d32b0 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -37,7 +37,7 @@ jobs: run: | sudo apt update sudo apt install build-essential cmake g++ - sudo apt install qtbase5-dev qtbase5-private-dev qttools5-dev qttools5-dev-tools libqt5svg5-dev libargon2-dev libminizip-dev libbotan-2-dev libqrencode-dev zlib1g-dev asciidoctor libreadline-dev libpcsclite-dev libusb-1.0-0-dev libxi-dev libxtst-dev libqt5x11extras5-dev + sudo apt install qtbase5-dev qtbase5-private-dev qttools5-dev qttools5-dev-tools libqt5svg5-dev libargon2-dev libkeyutils-dev libminizip-dev libbotan-2-dev libqrencode-dev zlib1g-dev asciidoctor libreadline-dev libpcsclite-dev libusb-1.0-0-dev libxi-dev libxtst-dev libqt5x11extras5-dev # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/cmake/CLangFormat.cmake b/cmake/CLangFormat.cmake index b2df97d4d3..7984f25286 100644 --- a/cmake/CLangFormat.cmake +++ b/cmake/CLangFormat.cmake @@ -18,7 +18,7 @@ set(EXCLUDED_DIRS src/thirdparty src/zxcvbn # objective-c directories - src/touchid + src/quickunlock/touchid src/autotype/mac src/gui/osutils/macutils) diff --git a/share/CMakeLists.txt b/share/CMakeLists.txt index 90f7e6e683..f120fc6e2c 100644 --- a/share/CMakeLists.txt +++ b/share/CMakeLists.txt @@ -58,7 +58,12 @@ if(UNIX AND NOT APPLE AND NOT HAIKU) EXCLUDE PATTERN "actions" EXCLUDE PATTERN "categories" EXCLUDE) endif(KEEPASSXC_DIST_FLATPAK) configure_file(linux/${APP_ID}.desktop.in ${CMAKE_CURRENT_BINARY_DIR}/linux/${APP_ID}.desktop @ONLY) + configure_file(linux/${APP_ID}.policy.in ${CMAKE_CURRENT_BINARY_DIR}/linux/${APP_ID}.policy @ONLY) + install(FILES ${CMAKE_CURRENT_BINARY_DIR}/linux/${APP_ID}.desktop DESTINATION ${CMAKE_INSTALL_DATADIR}/applications) + if("${CMAKE_SYSTEM}" MATCHES "Linux") + install(FILES ${CMAKE_CURRENT_BINARY_DIR}/linux/${APP_ID}.policy DESTINATION ${CMAKE_INSTALL_DATADIR}/polkit-1/actions) + endif() install(FILES linux/${APP_ID}.appdata.xml DESTINATION ${CMAKE_INSTALL_DATADIR}/metainfo) endif(UNIX AND NOT APPLE AND NOT HAIKU) diff --git a/share/linux/org.keepassxc.KeePassXC.policy.in b/share/linux/org.keepassxc.KeePassXC.policy.in new file mode 100644 index 0000000000..e5b837e0cc --- /dev/null +++ b/share/linux/org.keepassxc.KeePassXC.policy.in @@ -0,0 +1,18 @@ + + + + KeePassXC Developers + + @APP_ICON_NAME@ + + + Quick Unlock for a KeePassXC Database + Authentication is required to unlock a KeePassXC Database + + no + auth_self + + + diff --git a/share/translations/keepassxc_en.ts b/share/translations/keepassxc_en.ts index 04ce9a499e..bb55d604d1 100644 --- a/share/translations/keepassxc_en.ts +++ b/share/translations/keepassxc_en.ts @@ -1518,10 +1518,6 @@ To prevent this error from appearing, you must go to "Database Settings / S Retry with empty password - - Failed to authenticate with Touch ID - - Failed to open key file: %1 @@ -1576,11 +1572,7 @@ If you do not have a key file, please leave the field empty. - Failed to authenticate with Windows Hello: %1 - - - - Windows Hello setup was canceled or failed. Quick unlock has not been enabled. + Failed to authenticate with Quick Unlock: %1 @@ -7983,6 +7975,62 @@ Kernel: %3 %4 allow screenshots and app recording (Windows/macOS) + + AES initialization failed + + + + AES encrypt failed + + + + Failed to store in Linux Keyring + + + + Could not locate key in keyring + + + + Could not read key in keyring + + + + AES decrypt failed + + + + No Polkit authentication agent was available + + + + Polkit authorization failed + + + + No Quick Unlock provider is available + + + + Polkit returned an error: %1 + + + + Failed to init KeePassXC crypto. + + + + Failed to encrypt key data. + + + + Failed to get Windows Hello credential. + + + + Failed to decrypt key data. + + QtIOCompressor @@ -8998,25 +9046,6 @@ Example: JBSWY3DPEHPK3PXP - - WindowsHello - - Failed to init KeePassXC crypto. - - - - Failed to encrypt key data. - - - - Failed to get Windows Hello credential. - - - - Failed to decrypt key data. - - - YubiKey diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 056df57863..298355e8d7 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -194,6 +194,7 @@ set(keepassx_SOURCES streams/qtiocompressor.cpp streams/StoreDataStream.cpp streams/SymmetricCipherStream.cpp + quickunlock/QuickUnlockInterface.cpp totp/totp.cpp) if(APPLE) set(keepassx_SOURCES @@ -209,6 +210,12 @@ if(UNIX AND NOT APPLE) ${keepassx_SOURCES} gui/osutils/nixutils/ScreenLockListenerDBus.cpp gui/osutils/nixutils/NixUtils.cpp) + if("${CMAKE_SYSTEM}" MATCHES "Linux") + set(keepassx_SOURCES + ${keepassx_SOURCES} + quickunlock/Polkit.cpp + quickunlock/PolkitDbusTypes.cpp) + endif() if(WITH_XC_X11) list(APPEND keepassx_SOURCES gui/osutils/nixutils/X11Funcs.cpp) @@ -217,6 +224,21 @@ if(UNIX AND NOT APPLE) gui/org.keepassxc.KeePassXC.MainWindow.xml gui/MainWindow.h MainWindow) + + set_source_files_properties( + quickunlock/dbus/org.freedesktop.PolicyKit1.Authority.xml + PROPERTIES + INCLUDE "quickunlock/PolkitDbusTypes.h" + ) + qt5_add_dbus_interface(keepassx_SOURCES + quickunlock/dbus/org.freedesktop.PolicyKit1.Authority.xml + polkit_dbus + ) + + find_library(KEYUTILS_LIBRARIES NAMES keyutils) + if(NOT KEYUTILS_LIBRARIES) + message(FATAL_ERROR "Could not find libkeyutils") + endif() endif() if(WIN32) set(keepassx_SOURCES @@ -224,7 +246,7 @@ if(WIN32) gui/osutils/winutils/ScreenLockListenerWin.cpp gui/osutils/winutils/WinUtils.cpp) if (MSVC) - list(APPEND keepassx_SOURCES winhello/WindowsHello.cpp) + list(APPEND keepassx_SOURCES quickunlock/WindowsHello.cpp) endif() endif() @@ -316,9 +338,9 @@ if(WITH_XC_NETWORKING) endif() if(APPLE) - list(APPEND keepassx_SOURCES touchid/TouchID.mm) + list(APPEND keepassx_SOURCES quickunlock/TouchID.mm) # TODO: Remove -Wno-error once deprecation warnings have been resolved. - set_source_files_properties(touchid/TouchID.mm PROPERTY COMPILE_FLAGS "-Wno-old-style-cast") + set_source_files_properties(quickunlock/TouchID.mm PROPERTY COMPILE_FLAGS "-Wno-old-style-cast") endif() configure_file(config-keepassx.h.cmake ${CMAKE_CURRENT_BINARY_DIR}/config-keepassx.h) @@ -344,6 +366,7 @@ target_link_libraries(keepassx_core ${ZXCVBN_LIBRARIES} ${ZLIB_LIBRARIES} ${ARGON2_LIBRARIES} + ${KEYUTILS_LIBRARIES} ${thirdparty_LIBRARIES} ) diff --git a/src/core/Database.cpp b/src/core/Database.cpp index 488cc2e4e7..aa36dad126 100644 --- a/src/core/Database.cpp +++ b/src/core/Database.cpp @@ -113,6 +113,8 @@ bool Database::open(QSharedPointer key, QString* error) * Unless `readOnly` is set to false, the database will be opened in * read-write mode and fall back to read-only if that is not possible. * + * If key is provided as null, only headers will be read. + * * @param filePath path to the file * @param key composite key for unlocking the database * @param error error message in case of failure @@ -996,3 +998,14 @@ void Database::stopModifiedTimer() { QMetaObject::invokeMethod(&m_modifiedTimer, "stop"); } + +QUuid Database::publicUuid() +{ + + if (!publicCustomData().contains("KPXC_PUBLIC_UUID")) { + publicCustomData().insert("KPXC_PUBLIC_UUID", QUuid::createUuid().toRfc4122()); + markAsModified(); + } + + return QUuid::fromRfc4122(publicCustomData()["KPXC_PUBLIC_UUID"].toByteArray()); +} diff --git a/src/core/Database.h b/src/core/Database.h index 6d8e0403bf..d4a0a7bd51 100644 --- a/src/core/Database.h +++ b/src/core/Database.h @@ -102,6 +102,7 @@ class Database : public ModifiableObject bool hasNonDataChanges() const; bool isSaving(); + QUuid publicUuid(); QUuid uuid() const; QString filePath() const; QString canonicalFilePath() const; diff --git a/src/format/KdbxReader.cpp b/src/format/KdbxReader.cpp index 5610897c84..b552bd1cbe 100644 --- a/src/format/KdbxReader.cpp +++ b/src/format/KdbxReader.cpp @@ -27,6 +27,8 @@ /** * Read KDBX magic header numbers from a device. * + * Passing a null key will only read in the unprotected headers. + * * @param device input device * @param sig1 KDBX signature 1 * @param sig2 KDBX signature 2 @@ -55,6 +57,8 @@ bool KdbxReader::readMagicNumbers(QIODevice* device, quint32& sig1, quint32& sig * Read KDBX stream from device. * The device will automatically be reset to 0 before reading. * + * Passing a null key will only read in the unprotected headers. + * * @param device input device * @param key database encryption composite key * @param db database to read into @@ -91,6 +95,11 @@ bool KdbxReader::readDatabase(QIODevice* device, QSharedPointerEnableCopyOnDoubleClickCheckBox->setChecked( config()->get(Config::Security_EnableCopyOnDoubleClick).toBool()); - bool quickUnlockAvailable = false; -#if defined(Q_OS_MACOS) - quickUnlockAvailable = TouchID::getInstance().isAvailable(); -#elif defined(Q_CC_MSVC) - quickUnlockAvailable = getWindowsHello()->isAvailable(); - connect(getWindowsHello(), &WindowsHello::availableChanged, m_secUi->quickUnlockCheckBox, &QCheckBox::setEnabled); -#endif - m_secUi->quickUnlockCheckBox->setEnabled(quickUnlockAvailable); + m_secUi->quickUnlockCheckBox->setEnabled(getQuickUnlock()->isAvailable()); m_secUi->quickUnlockCheckBox->setChecked(config()->get(Config::Security_QuickUnlock).toBool()); for (const ExtraPage& page : asConst(m_extraPages)) { diff --git a/src/gui/DatabaseOpenWidget.cpp b/src/gui/DatabaseOpenWidget.cpp index 319fdf89ff..1cd176023f 100644 --- a/src/gui/DatabaseOpenWidget.cpp +++ b/src/gui/DatabaseOpenWidget.cpp @@ -26,19 +26,12 @@ #include "gui/MessageBox.h" #include "keys/ChallengeResponseKey.h" #include "keys/FileKey.h" - -#ifdef Q_OS_MACOS -#include "touchid/TouchID.h" -#endif -#ifdef Q_CC_MSVC -#include "winhello/WindowsHello.h" -#endif +#include "quickunlock/QuickUnlockInterface.h" #include #include #include #include - namespace { constexpr int clearFormsDelay = 30000; @@ -46,25 +39,17 @@ namespace bool isQuickUnlockAvailable() { if (config()->get(Config::Security_QuickUnlock).toBool()) { -#if defined(Q_CC_MSVC) - return getWindowsHello()->isAvailable(); -#elif defined(Q_OS_MACOS) - return TouchID::getInstance().isAvailable(); -#endif + return getQuickUnlock()->isAvailable(); } return false; } - bool canPerformQuickUnlock(const QString& filename) + bool canPerformQuickUnlock(const QUuid& dbUuid) { if (isQuickUnlockAvailable()) { -#if defined(Q_CC_MSVC) - return getWindowsHello()->hasKey(filename); -#elif defined(Q_OS_MACOS) - return TouchID::getInstance().containsKey(filename); -#endif + return getQuickUnlock()->hasKey(dbUuid); } - Q_UNUSED(filename); + Q_UNUSED(dbUuid); return false; } } // namespace @@ -149,7 +134,7 @@ void DatabaseOpenWidget::showEvent(QShowEvent* event) DialogyWidget::showEvent(event); if (isOnQuickUnlockScreen()) { m_ui->quickUnlockButton->setFocus(); - if (!canPerformQuickUnlock(m_filename)) { + if (m_db.isNull() || !canPerformQuickUnlock(m_db->publicUuid())) { resetQuickUnlock(); } } else { @@ -178,6 +163,12 @@ void DatabaseOpenWidget::load(const QString& filename) clearForms(); m_filename = filename; + + // Read public headers + QString error; + m_db.reset(new Database()); + m_db->open(m_filename, nullptr, &error); + m_ui->fileNameLabel->setRawText(m_filename); if (config()->get(Config::RememberLastKeyFiles).toBool()) { @@ -187,7 +178,7 @@ void DatabaseOpenWidget::load(const QString& filename) } } - if (canPerformQuickUnlock(m_filename)) { + if (canPerformQuickUnlock(m_db->publicUuid())) { m_ui->centralStack->setCurrentIndex(1); m_ui->quickUnlockButton->setFocus(); } else { @@ -215,7 +206,10 @@ void DatabaseOpenWidget::clearForms() m_ui->keyFileLineEdit->setClearButtonEnabled(true); m_ui->challengeResponseCombo->clear(); m_ui->centralStack->setCurrentIndex(0); - m_db.reset(); + + QString error; + m_db.reset(new Database()); + m_db->open(m_filename, nullptr, &error); } QSharedPointer DatabaseOpenWidget::database() @@ -274,6 +268,8 @@ void DatabaseOpenWidget::openDatabase() msgBox->exec(); if (msgBox->clickedButton() != btn) { m_db.reset(new Database()); + m_db->open(m_filename, nullptr, &error); + m_ui->messageWidget->showMessage(tr("Database unlock canceled."), MessageWidget::MessageType::Error); setUserInteractionLock(false); return; @@ -283,17 +279,7 @@ void DatabaseOpenWidget::openDatabase() // Save Quick Unlock credentials if available if (!blockQuickUnlock && isQuickUnlockAvailable()) { auto keyData = databaseKey->serialize(); -#if defined(Q_CC_MSVC) - // Store the password using Windows Hello - if (!getWindowsHello()->storeKey(m_filename, keyData)) { - getMainWindow()->displayTabMessage( - tr("Windows Hello setup was canceled or failed. Quick unlock has not been enabled."), - MessageWidget::MessageType::Warning); - } -#elif defined(Q_OS_MACOS) - // Store the password using TouchID - TouchID::getInstance().storeKey(m_filename, keyData); -#endif + getQuickUnlock()->setKey(m_db->publicUuid(), keyData); m_ui->messageWidget->hideMessage(); } @@ -338,27 +324,15 @@ QSharedPointer DatabaseOpenWidget::buildDatabaseKey() { auto databaseKey = QSharedPointer::create(); - if (canPerformQuickUnlock(m_filename)) { + if (!m_db.isNull() && canPerformQuickUnlock(m_db->publicUuid())) { // try to retrieve the stored password using Windows Hello QByteArray keyData; -#ifdef Q_CC_MSVC - if (!getWindowsHello()->getKey(m_filename, keyData)) { - // Failed to retrieve Quick Unlock data - auto error = getWindowsHello()->errorString(); - if (!error.isEmpty()) { - m_ui->messageWidget->showMessage(tr("Failed to authenticate with Windows Hello: %1").arg(error), - MessageWidget::Error); - resetQuickUnlock(); - } - return {}; - } -#elif defined(Q_OS_MACOS) - if (!TouchID::getInstance().getKey(m_filename, keyData)) { - // Failed to retrieve Quick Unlock data - m_ui->messageWidget->showMessage(tr("Failed to authenticate with Touch ID"), MessageWidget::Error); + if (!getQuickUnlock()->getKey(m_db->publicUuid(), keyData)) { + m_ui->messageWidget->showMessage( + tr("Failed to authenticate with Quick Unlock: %1").arg(getQuickUnlock()->errorString()), + MessageWidget::Error); return {}; } -#endif databaseKey->setRawKey(keyData); return databaseKey; } @@ -553,10 +527,11 @@ void DatabaseOpenWidget::triggerQuickUnlock() */ void DatabaseOpenWidget::resetQuickUnlock() { -#if defined(Q_CC_MSVC) - getWindowsHello()->reset(m_filename); -#elif defined(Q_OS_MACOS) - TouchID::getInstance().reset(m_filename); -#endif + if (!isQuickUnlockAvailable()) { + return; + } + if (!m_db.isNull()) { + getQuickUnlock()->reset(m_db->publicUuid()); + } load(m_filename); } diff --git a/src/gui/dbsettings/DatabaseSettingsWidgetDatabaseKey.cpp b/src/gui/dbsettings/DatabaseSettingsWidgetDatabaseKey.cpp index 41b4eea6de..bac749979c 100644 --- a/src/gui/dbsettings/DatabaseSettingsWidgetDatabaseKey.cpp +++ b/src/gui/dbsettings/DatabaseSettingsWidgetDatabaseKey.cpp @@ -25,13 +25,7 @@ #include "keys/ChallengeResponseKey.h" #include "keys/FileKey.h" #include "keys/PasswordKey.h" - -#ifdef Q_OS_MACOS -#include "touchid/TouchID.h" -#endif -#ifdef Q_CC_MSVC -#include "winhello/WindowsHello.h" -#endif +#include "quickunlock/QuickUnlockInterface.h" #include #include @@ -198,11 +192,7 @@ bool DatabaseSettingsWidgetDatabaseKey::save() m_db->setKey(newKey, true, false, false); -#if defined(Q_OS_MACOS) - TouchID::getInstance().reset(m_db->filePath()); -#elif defined(Q_CC_MSVC) - getWindowsHello()->reset(m_db->filePath()); -#endif + getQuickUnlock()->reset(m_db->publicUuid()); emit editFinished(true); if (m_isDirty) { diff --git a/src/gui/osutils/nixutils/NixUtils.cpp b/src/gui/osutils/nixutils/NixUtils.cpp index ebbea91d36..194b62058e 100644 --- a/src/gui/osutils/nixutils/NixUtils.cpp +++ b/src/gui/osutils/nixutils/NixUtils.cpp @@ -21,6 +21,7 @@ #include #include +#include #include #include #include @@ -323,3 +324,29 @@ void NixUtils::setColorScheme(QDBusVariant value) m_systemColorschemePrefExists = true; emit interfaceThemeChanged(); } + +quint64 NixUtils::getProcessStartTime() const +{ + QString processStatPath = QString("/proc/%1/stat").arg(QCoreApplication::applicationPid()); + QFile processStatFile(processStatPath); + + if (!processStatFile.open(QIODevice::ReadOnly | QIODevice::Text)) { + qDebug() << "nixutils: failed to open " << processStatPath; + return 0; + } + + QTextStream processStatStream(&processStatFile); + QString processStatInfo = processStatStream.readLine(); + processStatFile.close(); + + auto startIndex = processStatInfo.indexOf(')', -1); + if (startIndex != -1) { + auto tokens = processStatInfo.midRef(startIndex + 2).split(' '); + if (tokens.size() >= 20) { + return tokens[19].toULongLong(); + } + } + + qDebug() << "nixutils: failed to parse " << processStatPath; + return 0; +} diff --git a/src/gui/osutils/nixutils/NixUtils.h b/src/gui/osutils/nixutils/NixUtils.h index e3a17b9509..04cd086287 100644 --- a/src/gui/osutils/nixutils/NixUtils.h +++ b/src/gui/osutils/nixutils/NixUtils.h @@ -49,6 +49,8 @@ class NixUtils : public OSUtilsBase, QAbstractNativeEventFilter return false; } + quint64 getProcessStartTime() const; + private slots: void handleColorSchemeRead(QDBusVariant value); void handleColorSchemeChanged(QString ns, QString key, QDBusVariant value); diff --git a/src/gui/reports/ReportsDialog.cpp b/src/gui/reports/ReportsDialog.cpp index 123e02c2cb..22a7425d57 100644 --- a/src/gui/reports/ReportsDialog.cpp +++ b/src/gui/reports/ReportsDialog.cpp @@ -30,9 +30,6 @@ #include "core/Global.h" #include "core/Group.h" -#ifdef Q_OS_MACOS -#include "touchid/TouchID.h" -#endif class ReportsDialog::ExtraPage { diff --git a/src/quickunlock/Polkit.cpp b/src/quickunlock/Polkit.cpp new file mode 100644 index 0000000000..38b9380d6d --- /dev/null +++ b/src/quickunlock/Polkit.cpp @@ -0,0 +1,247 @@ +/* + * Copyright (C) 2023 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "Polkit.h" + +#include "crypto/CryptoHash.h" +#include "crypto/Random.h" +#include "crypto/SymmetricCipher.h" +#include "gui/osutils/nixutils/NixUtils.h" + +#include +#include +#include +#include +#include + +extern "C" { +#include +} + +const QString polkit_service = "org.freedesktop.PolicyKit1"; +const QString polkit_object = "/org/freedesktop/PolicyKit1/Authority"; + +namespace +{ + QString getKeyName(const QUuid& dbUuid) + { + static const QString keyPrefix = "keepassxc_polkit_keys_"; + return keyPrefix + dbUuid.toString(); + } +} // namespace + +Polkit::Polkit() +{ + PolkitSubject::registerMetaType(); + PolkitAuthorizationResults::registerMetaType(); + + /* Note we explicitly use our own dbus path here, as the ::systemBus() method could be overriden + through an environment variable to return an alternative bus path. This bus could have an application + pretending to be polkit running on it, which could approve every authentication request + + Most Linux distros place the system bus at this exact path, so it is hard-coded. + For any other distros, this path will need to be patched before compilation. + */ + QDBusConnection bus = + QDBusConnection::connectToBus("unix:path=/run/dbus/system_bus_socket", "keepassxc_polkit_dbus"); + + m_available = bus.isConnected(); + if (!m_available) { + qDebug() << "polkit: Failed to connect to system dbus (this may be due to a non-standard dbus path)"; + return; + } + + m_available = bus.interface()->isServiceRegistered(polkit_service); + + if (!m_available) { + qDebug() << "polkit: Polkit is not registered on dbus"; + return; + } + + m_polkit.reset(new org::freedesktop::PolicyKit1::Authority(polkit_service, polkit_object, bus)); +} + +Polkit::~Polkit() +{ +} + +void Polkit::reset(const QUuid& dbUuid) +{ + m_encryptedMasterKeys.remove(dbUuid); +} + +bool Polkit::isAvailable() const +{ + return m_available; +} + +QString Polkit::errorString() const +{ + return m_error; +} + +void Polkit::reset() +{ + m_encryptedMasterKeys.clear(); +} + +bool Polkit::setKey(const QUuid& dbUuid, const QByteArray& key) +{ + reset(dbUuid); + + // Generate a random iv/key pair to encrypt the master password with + QByteArray randomKey = randomGen()->randomArray(SymmetricCipher::keySize(SymmetricCipher::Aes256_GCM)); + QByteArray randomIV = randomGen()->randomArray(SymmetricCipher::defaultIvSize(SymmetricCipher::Aes256_GCM)); + QByteArray keychainKeyValue = randomKey + randomIV; + + SymmetricCipher aes256Encrypt; + if (!aes256Encrypt.init(SymmetricCipher::Aes256_GCM, SymmetricCipher::Encrypt, randomKey, randomIV)) { + m_error = QObject::tr("AES initialization failed"); + return false; + } + + // Encrypt the master password + QByteArray encryptedMasterKey = key; + if (!aes256Encrypt.finish(encryptedMasterKey)) { + m_error = QObject::tr("AES encrypt failed"); + qDebug() << "polkit aes encrypt failed: " << aes256Encrypt.errorString(); + return false; + } + + // Add the iv/key pair into the linux keyring + key_serial_t key_serial = add_key("user", + getKeyName(dbUuid).toStdString().c_str(), + keychainKeyValue.constData(), + keychainKeyValue.size(), + KEY_SPEC_PROCESS_KEYRING); + if (key_serial < 0) { + m_error = QObject::tr("Failed to store in Linux Keyring"); + qDebug() << "polkit keyring failed to store: " << errno; + return false; + } + + // Scrub the keys from ram + Botan::secure_scrub_memory(randomKey.data(), randomKey.size()); + Botan::secure_scrub_memory(randomIV.data(), randomIV.size()); + Botan::secure_scrub_memory(keychainKeyValue.data(), keychainKeyValue.size()); + + // Store encrypted master password and return + m_encryptedMasterKeys.insert(dbUuid, encryptedMasterKey); + return true; +} + +bool Polkit::getKey(const QUuid& dbUuid, QByteArray& key) +{ + if (!m_polkit || !hasKey(dbUuid)) { + return false; + } + + PolkitSubject subject; + subject.kind = "unix-process"; + subject.details.insert("pid", static_cast(QCoreApplication::applicationPid())); + subject.details.insert("start-time", nixUtils()->getProcessStartTime()); + + QMap details; + + auto result = m_polkit->CheckAuthorization( + subject, + "org.keepassxc.KeePassXC.unlockDatabase", + details, + 0x00000001, + // AllowUserInteraction - wait for user to authenticate + // https://www.freedesktop.org/software/polkit/docs/0.105/eggdbus-interface-org.freedesktop.PolicyKit1.Authority.html#eggdbus-enum-CheckAuthorizationFlags + ""); + + // A general error occurred + if (result.isError()) { + auto msg = result.error().message(); + m_error = QObject::tr("Polkit returned an error: %1").arg(msg); + qDebug() << "polkit returned an error: " << msg; + return false; + } + + PolkitAuthorizationResults authResult = result.value(); + if (authResult.is_authorized) { + QByteArray encryptedMasterKey = m_encryptedMasterKeys.value(dbUuid); + key_serial_t keySerial = + find_key_by_type_and_desc("user", getKeyName(dbUuid).toStdString().c_str(), KEY_SPEC_PROCESS_KEYRING); + + if (keySerial == -1) { + m_error = QObject::tr("Could not locate key in keyring"); + qDebug() << "polkit keyring failed to find: " << errno; + return false; + } + + void* keychainBuffer; + long keychainDataSize = keyctl_read_alloc(keySerial, &keychainBuffer); + + if (keychainDataSize == -1) { + m_error = QObject::tr("Could not read key in keyring"); + qDebug() << "polkit keyring failed to read: " << errno; + return false; + } + + QByteArray keychainBytes(static_cast(keychainBuffer), keychainDataSize); + + Botan::secure_scrub_memory(keychainBuffer, keychainDataSize); + free(keychainBuffer); + + QByteArray keychainKey = keychainBytes.left(SymmetricCipher::keySize(SymmetricCipher::Aes256_GCM)); + QByteArray keychainIv = keychainBytes.right(SymmetricCipher::defaultIvSize(SymmetricCipher::Aes256_GCM)); + + SymmetricCipher aes256Decrypt; + if (!aes256Decrypt.init(SymmetricCipher::Aes256_GCM, SymmetricCipher::Decrypt, keychainKey, keychainIv)) { + m_error = QObject::tr("AES initialization failed"); + qDebug() << "polkit aes init failed"; + return false; + } + + key = encryptedMasterKey; + if (!aes256Decrypt.finish(key)) { + key.clear(); + m_error = QObject::tr("AES decrypt failed"); + qDebug() << "polkit aes decrypt failed: " << aes256Decrypt.errorString(); + return false; + } + + // Scrub the keys from ram + Botan::secure_scrub_memory(keychainKey.data(), keychainKey.size()); + Botan::secure_scrub_memory(keychainIv.data(), keychainIv.size()); + Botan::secure_scrub_memory(keychainBytes.data(), keychainBytes.size()); + Botan::secure_scrub_memory(encryptedMasterKey.data(), encryptedMasterKey.size()); + + return true; + } + + // Failed to authenticate + if (authResult.is_challenge) { + m_error = QObject::tr("No Polkit authentication agent was available"); + } else { + m_error = QObject::tr("Polkit authorization failed"); + } + + return false; +} + +bool Polkit::hasKey(const QUuid& dbUuid) const +{ + if (!m_encryptedMasterKeys.contains(dbUuid)) { + return false; + } + + return find_key_by_type_and_desc("user", getKeyName(dbUuid).toStdString().c_str(), KEY_SPEC_PROCESS_KEYRING) != -1; +} diff --git a/src/quickunlock/Polkit.h b/src/quickunlock/Polkit.h new file mode 100644 index 0000000000..7dfc2db7b1 --- /dev/null +++ b/src/quickunlock/Polkit.h @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2023 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEEPASSX_POLKIT_H +#define KEEPASSX_POLKIT_H + +#include "QuickUnlockInterface.h" +#include "polkit_dbus.h" +#include +#include + +class Polkit : public QuickUnlockInterface +{ +public: + Polkit(); + ~Polkit() override; + + bool isAvailable() const override; + QString errorString() const override; + + bool setKey(const QUuid& dbUuid, const QByteArray& key) override; + bool getKey(const QUuid& dbUuid, QByteArray& key) override; + bool hasKey(const QUuid& dbUuid) const override; + + void reset(const QUuid& dbUuid) override; + void reset() override; + +private: + bool m_available; + QString m_error; + QHash m_encryptedMasterKeys; + + QScopedPointer m_polkit; +}; + +#endif // KEEPASSX_POLKIT_H diff --git a/src/quickunlock/PolkitDbusTypes.cpp b/src/quickunlock/PolkitDbusTypes.cpp new file mode 100644 index 0000000000..a4305dc445 --- /dev/null +++ b/src/quickunlock/PolkitDbusTypes.cpp @@ -0,0 +1,45 @@ +#include "PolkitDbusTypes.h" + +void PolkitSubject::registerMetaType() +{ + qRegisterMetaType("PolkitSubject"); + qDBusRegisterMetaType(); +} + +QDBusArgument& operator<<(QDBusArgument& argument, const PolkitSubject& subject) +{ + argument.beginStructure(); + argument << subject.kind << subject.details; + argument.endStructure(); + return argument; +} + +const QDBusArgument& operator>>(const QDBusArgument& argument, PolkitSubject& subject) +{ + argument.beginStructure(); + argument >> subject.kind >> subject.details; + argument.endStructure(); + return argument; +} + +void PolkitAuthorizationResults::registerMetaType() +{ + qRegisterMetaType("PolkitAuthorizationResults"); + qDBusRegisterMetaType(); +} + +QDBusArgument& operator<<(QDBusArgument& argument, const PolkitAuthorizationResults& res) +{ + argument.beginStructure(); + argument << res.is_authorized << res.is_challenge << res.details; + argument.endStructure(); + return argument; +} + +const QDBusArgument& operator>>(const QDBusArgument& argument, PolkitAuthorizationResults& res) +{ + argument.beginStructure(); + argument >> res.is_authorized >> res.is_challenge >> res.details; + argument.endStructure(); + return argument; +} diff --git a/src/quickunlock/PolkitDbusTypes.h b/src/quickunlock/PolkitDbusTypes.h new file mode 100644 index 0000000000..83eb238893 --- /dev/null +++ b/src/quickunlock/PolkitDbusTypes.h @@ -0,0 +1,36 @@ +#ifndef KEEPASSX_POLKITDBUSTYPES_H +#define KEEPASSX_POLKITDBUSTYPES_H + +#include + +class PolkitSubject +{ +public: + QString kind; + QVariantMap details; + + static void registerMetaType(); + + friend QDBusArgument& operator<<(QDBusArgument& argument, const PolkitSubject& subject); + + friend const QDBusArgument& operator>>(const QDBusArgument& argument, PolkitSubject& subject); +}; + +class PolkitAuthorizationResults +{ +public: + bool is_authorized; + bool is_challenge; + QMap details; + + static void registerMetaType(); + + friend QDBusArgument& operator<<(QDBusArgument& argument, const PolkitAuthorizationResults& subject); + + friend const QDBusArgument& operator>>(const QDBusArgument& argument, PolkitAuthorizationResults& subject); +}; + +Q_DECLARE_METATYPE(PolkitSubject); +Q_DECLARE_METATYPE(PolkitAuthorizationResults); + +#endif // KEEPASSX_POLKITDBUSTYPES_H diff --git a/src/quickunlock/QuickUnlockInterface.cpp b/src/quickunlock/QuickUnlockInterface.cpp new file mode 100644 index 0000000000..0e24736e80 --- /dev/null +++ b/src/quickunlock/QuickUnlockInterface.cpp @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2023 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "QuickUnlockInterface.h" +#include + +#if defined(Q_OS_MACOS) +#include "TouchID.h" +#define QUICKUNLOCK_IMPLEMENTATION TouchID +#elif defined(Q_CC_MSVC) +#include "WindowsHello.h" +#define QUICKUNLOCK_IMPLEMENTATION WindowsHello +#elif defined(Q_OS_LINUX) +#include "Polkit.h" +#define QUICKUNLOCK_IMPLEMENTATION Polkit +#else +#define QUICKUNLOCK_IMPLEMENTATION NoQuickUnlock +#endif + +QUICKUNLOCK_IMPLEMENTATION* quickUnlockInstance = {nullptr}; + +QuickUnlockInterface* getQuickUnlock() +{ + if (!quickUnlockInstance) { + quickUnlockInstance = new QUICKUNLOCK_IMPLEMENTATION(); + } + return quickUnlockInstance; +} + +bool NoQuickUnlock::isAvailable() const +{ + return false; +} + +QString NoQuickUnlock::errorString() const +{ + return QObject::tr("No Quick Unlock provider is available"); +} + +void NoQuickUnlock::reset() +{ +} + +bool NoQuickUnlock::setKey(const QUuid& dbUuid, const QByteArray& key) +{ + Q_UNUSED(dbUuid) + Q_UNUSED(key) + return false; +} + +bool NoQuickUnlock::getKey(const QUuid& dbUuid, QByteArray& key) +{ + Q_UNUSED(dbUuid) + Q_UNUSED(key) + return false; +} + +bool NoQuickUnlock::hasKey(const QUuid& dbUuid) const +{ + Q_UNUSED(dbUuid) + return false; +} + +void NoQuickUnlock::reset(const QUuid& dbUuid) +{ + Q_UNUSED(dbUuid) +} diff --git a/src/quickunlock/QuickUnlockInterface.h b/src/quickunlock/QuickUnlockInterface.h new file mode 100644 index 0000000000..54aeb8a627 --- /dev/null +++ b/src/quickunlock/QuickUnlockInterface.h @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2023 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEEPASSXC_QUICKUNLOCKINTERFACE_H +#define KEEPASSXC_QUICKUNLOCKINTERFACE_H + +#include + +class QuickUnlockInterface +{ + Q_DISABLE_COPY(QuickUnlockInterface) + +public: + QuickUnlockInterface() = default; + virtual ~QuickUnlockInterface() = default; + + virtual bool isAvailable() const = 0; + virtual QString errorString() const = 0; + + virtual bool setKey(const QUuid& dbUuid, const QByteArray& key) = 0; + virtual bool getKey(const QUuid& dbUuid, QByteArray& key) = 0; + virtual bool hasKey(const QUuid& dbUuid) const = 0; + + virtual void reset(const QUuid& dbUuid) = 0; + virtual void reset() = 0; +}; + +class NoQuickUnlock : public QuickUnlockInterface +{ +public: + bool isAvailable() const override; + QString errorString() const override; + + bool setKey(const QUuid& dbUuid, const QByteArray& key) override; + bool getKey(const QUuid& dbUuid, QByteArray& key) override; + bool hasKey(const QUuid& dbUuid) const override; + + void reset(const QUuid& dbUuid) override; + void reset() override; +}; + +QuickUnlockInterface* getQuickUnlock(); + +#endif // KEEPASSXC_QUICKUNLOCKINTERFACE_H diff --git a/src/quickunlock/TouchID.h b/src/quickunlock/TouchID.h new file mode 100644 index 0000000000..2cca7ea464 --- /dev/null +++ b/src/quickunlock/TouchID.h @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2023 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEEPASSX_TOUCHID_H +#define KEEPASSX_TOUCHID_H + +#include "QuickUnlockInterface.h" +#include + +class TouchID : public QuickUnlockInterface +{ +public: + bool isAvailable() const override; + QString errorString() const override; + + bool setKey(const QUuid& dbUuid, const QByteArray& passwordKey) override; + bool getKey(const QUuid& dbUuid, QByteArray& passwordKey) override; + bool hasKey(const QUuid& dbUuid) const override; + + void reset(const QUuid& dbUuid = "") override; + void reset() override; + +private: + static bool isWatchAvailable(); + static bool isTouchIdAvailable(); + + static void deleteKeyEntry(const QString& accountName); + static QString databaseKeyName(const QUuid& dbUuid); + + QHash m_encryptedMasterKeys; +}; + +#endif // KEEPASSX_TOUCHID_H diff --git a/src/touchid/TouchID.mm b/src/quickunlock/TouchID.mm similarity index 85% rename from src/touchid/TouchID.mm rename to src/quickunlock/TouchID.mm index cc858a89a8..502a508c4b 100644 --- a/src/touchid/TouchID.mm +++ b/src/quickunlock/TouchID.mm @@ -1,4 +1,4 @@ -#include "touchid/TouchID.h" +#include "quickunlock/TouchID.h" #include "crypto/Random.h" #include "crypto/SymmetricCipher.h" @@ -13,6 +13,7 @@ #include #include +#include #define TOUCH_ID_ENABLE_DEBUG_LOGS() 0 #if TOUCH_ID_ENABLE_DEBUG_LOGS() @@ -54,16 +55,6 @@ inline CFMutableDictionaryRef makeDictionary() { return CFDictionaryCreateMutable(NULL, 0, &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks); } -/** - * Singleton - */ -TouchID& TouchID::getInstance() -{ - static TouchID instance; // Guaranteed to be destroyed. - // Instantiated on first use. - return instance; -} - //! Try to delete an existing keychain entry void TouchID::deleteKeyEntry(const QString& accountName) { @@ -77,14 +68,24 @@ inline CFMutableDictionaryRef makeDictionary() { // get data from the KeyChain OSStatus status = SecItemDelete(query); - LogStatusError("TouchID::storeKey - Status deleting existing entry", status); + LogStatusError("TouchID::deleteKeyEntry - Status deleting existing entry", status); } -QString TouchID::databaseKeyName(const QString &databasePath) +QString TouchID::databaseKeyName(const QUuid& dbUuid) { static const QString keyPrefix = "KeepassXC_TouchID_Keys_"; - const QByteArray pathHash = CryptoHash::hash(databasePath.toUtf8(), CryptoHash::Sha256).toHex(); - return keyPrefix + pathHash; + return keyPrefix + dbUuid.toString(); +} + +QString TouchID::errorString() const +{ + // TODO + return ""; +} + +void TouchID::reset() +{ + m_encryptedMasterKeys.clear(); } /** @@ -92,15 +93,15 @@ inline CFMutableDictionaryRef makeDictionary() { * protects the database. The encrypted PasswordKey is kept in memory while the * AES key is stored in the macOS KeyChain protected by either TouchID or Apple Watch. */ -bool TouchID::storeKey(const QString& databasePath, const QByteArray& passwordKey) +bool TouchID::setKey(const QUuid& dbUuid, const QByteArray& passwordKey) { - if (databasePath.isEmpty() || passwordKey.isEmpty()) { - debug("TouchID::storeKey - illegal arguments"); + if (passwordKey.isEmpty()) { + debug("TouchID::setKey - illegal arguments"); return false; } - if (m_encryptedMasterKeys.contains(databasePath)) { - debug("TouchID::storeKey - Already stored key for this database"); + if (m_encryptedMasterKeys.contains(dbUuid)) { + debug("TouchID::setKey - Already stored key for this database"); return true; } @@ -110,7 +111,7 @@ inline CFMutableDictionaryRef makeDictionary() { SymmetricCipher aes256Encrypt; if (!aes256Encrypt.init(SymmetricCipher::Aes256_GCM, SymmetricCipher::Encrypt, randomKey, randomIV)) { - debug("TouchID::storeKey - AES initialisation failed"); + debug("TouchID::setKey - AES initialisation failed"); return false; } @@ -121,7 +122,7 @@ inline CFMutableDictionaryRef makeDictionary() { return false; } - const QString keyName = databaseKeyName(databasePath); + const QString keyName = databaseKeyName(dbUuid); deleteKeyEntry(keyName); // Try to delete the existing key entry @@ -152,7 +153,7 @@ inline CFMutableDictionaryRef makeDictionary() { if (sacObject == NULL || error != NULL) { NSError* e = (__bridge NSError*) error; - debug("TouchID::storeKey - Error creating security flags: %s", e.localizedDescription.UTF8String); + debug("TouchID::setKey - Error creating security flags: %s", e.localizedDescription.UTF8String); return false; } @@ -174,7 +175,7 @@ inline CFMutableDictionaryRef makeDictionary() { // add to KeyChain OSStatus status = SecItemAdd(attributes, NULL); - LogStatusError("TouchID::storeKey - Status adding new entry", status); + LogStatusError("TouchID::setKey - Status adding new entry", status); CFRelease(sacObject); CFRelease(attributes); @@ -188,8 +189,8 @@ inline CFMutableDictionaryRef makeDictionary() { Botan::secure_scrub_memory(randomIV.data(), randomIV.size()); // memorize which database the stored key is for - m_encryptedMasterKeys.insert(databasePath, encryptedMasterKey); - debug("TouchID::storeKey - Success!"); + m_encryptedMasterKeys.insert(dbUuid, encryptedMasterKey); + debug("TouchID::setKey - Success!"); return true; } @@ -197,15 +198,11 @@ inline CFMutableDictionaryRef makeDictionary() { * Checks if an encrypted PasswordKey is available for the given database, tries to * decrypt it using the KeyChain and if successful, returns it. */ -bool TouchID::getKey(const QString& databasePath, QByteArray& passwordKey) const +bool TouchID::getKey(const QUuid& dbUuid, QByteArray& passwordKey) { passwordKey.clear(); - if (databasePath.isEmpty()) { - debug("TouchID::getKey - missing database path"); - return false; - } - if (!containsKey(databasePath)) { + if (!hasKey(dbUuid)) { debug("TouchID::getKey - No stored key found"); return false; } @@ -213,7 +210,7 @@ inline CFMutableDictionaryRef makeDictionary() { // query the KeyChain for the AES key CFMutableDictionaryRef query = makeDictionary(); - const QString keyName = databaseKeyName(databasePath); + const QString keyName = databaseKeyName(dbUuid); NSString* accountName = keyName.toNSString(); // The NSString is released by Qt NSString* touchPromptMessage = QCoreApplication::translate("DatabaseOpenWidget", "authenticate to access the database") @@ -254,7 +251,7 @@ inline CFMutableDictionaryRef makeDictionary() { } // decrypt PasswordKey from memory using AES - passwordKey = m_encryptedMasterKeys[databasePath]; + passwordKey = m_encryptedMasterKeys[dbUuid]; if (!aes256Decrypt.finish(passwordKey)) { passwordKey.clear(); debug("TouchID::getKey - AES decrypt failed: %s", aes256Decrypt.errorString().toUtf8().constData()); @@ -268,9 +265,9 @@ inline CFMutableDictionaryRef makeDictionary() { return true; } -bool TouchID::containsKey(const QString& dbPath) const +bool TouchID::hasKey(const QUuid& dbUuid) const { - return m_encryptedMasterKeys.contains(dbPath); + return m_encryptedMasterKeys.contains(dbUuid); } // TODO: Both functions below should probably handle the returned errors to @@ -336,7 +333,7 @@ inline CFMutableDictionaryRef makeDictionary() { } //! @return true if either TouchID or Apple Watch is available at the moment. -bool TouchID::isAvailable() +bool TouchID::isAvailable() const { // note: we cannot cache the check results because the configuration // is dynamic in its nature. User can close the laptop lid or take off @@ -349,12 +346,7 @@ inline CFMutableDictionaryRef makeDictionary() { /** * Resets the inner state either for all or for the given database */ -void TouchID::reset(const QString& databasePath) +void TouchID::reset(const QUuid& dbUuid) { - if (databasePath.isEmpty()) { - m_encryptedMasterKeys.clear(); - return; - } - - m_encryptedMasterKeys.remove(databasePath); + m_encryptedMasterKeys.remove(dbUuid); } diff --git a/src/winhello/WindowsHello.cpp b/src/quickunlock/WindowsHello.cpp similarity index 82% rename from src/winhello/WindowsHello.cpp rename to src/quickunlock/WindowsHello.cpp index bc244cc26c..890e3499a5 100644 --- a/src/winhello/WindowsHello.cpp +++ b/src/quickunlock/WindowsHello.cpp @@ -99,28 +99,10 @@ namespace } } // namespace -WindowsHello* WindowsHello::m_instance{nullptr}; -WindowsHello* WindowsHello::instance() -{ - if (!m_instance) { - m_instance = new WindowsHello(); - } - return m_instance; -} - -WindowsHello::WindowsHello(QObject* parent) - : QObject(parent) -{ - concurrency::create_task([this] { - bool state = KeyCredentialManager::IsSupportedAsync().get(); - m_available = state; - emit availableChanged(m_available); - }); -} - bool WindowsHello::isAvailable() const { - return m_available; + auto task = concurrency::create_task([] { return KeyCredentialManager::IsSupportedAsync().get(); }); + return task.get(); } QString WindowsHello::errorString() const @@ -128,7 +110,7 @@ QString WindowsHello::errorString() const return m_error; } -bool WindowsHello::storeKey(const QString& dbPath, const QByteArray& data) +bool WindowsHello::setKey(const QUuid& dbUuid, const QByteArray& data) { queueSecurityPromptFocus(); @@ -144,26 +126,26 @@ bool WindowsHello::storeKey(const QString& dbPath, const QByteArray& data) // Encrypt the data using AES-256-CBC SymmetricCipher cipher; if (!cipher.init(SymmetricCipher::Aes256_GCM, SymmetricCipher::Encrypt, key, challenge)) { - m_error = tr("Failed to init KeePassXC crypto."); + m_error = QObject::tr("Failed to init KeePassXC crypto."); return false; } QByteArray encrypted = data; if (!cipher.finish(encrypted)) { - m_error = tr("Failed to encrypt key data."); + m_error = QObject::tr("Failed to encrypt key data."); return false; } // Prepend the challenge/IV to the encrypted data encrypted.prepend(challenge); - m_encryptedKeys.insert(dbPath, encrypted); + m_encryptedKeys.insert(dbUuid, encrypted); return true; } -bool WindowsHello::getKey(const QString& dbPath, QByteArray& data) +bool WindowsHello::getKey(const QUuid& dbUuid, QByteArray& data) { data.clear(); - if (!hasKey(dbPath)) { - m_error = tr("Failed to get Windows Hello credential."); + if (!hasKey(dbUuid)) { + m_error = QObject::tr("Failed to get Windows Hello credential."); return false; } @@ -171,7 +153,7 @@ bool WindowsHello::getKey(const QString& dbPath, QByteArray& data) // Read the previously used challenge and encrypted data auto ivSize = SymmetricCipher::defaultIvSize(SymmetricCipher::Aes256_GCM); - const auto& keydata = m_encryptedKeys.value(dbPath); + const auto& keydata = m_encryptedKeys.value(dbUuid); auto challenge = keydata.left(ivSize); auto encrypted = keydata.mid(ivSize); QByteArray key; @@ -183,7 +165,7 @@ bool WindowsHello::getKey(const QString& dbPath, QByteArray& data) // Decrypt the data using the generated key and IV from above SymmetricCipher cipher; if (!cipher.init(SymmetricCipher::Aes256_GCM, SymmetricCipher::Decrypt, key, challenge)) { - m_error = tr("Failed to init KeePassXC crypto."); + m_error = QObject::tr("Failed to init KeePassXC crypto."); return false; } @@ -191,21 +173,21 @@ bool WindowsHello::getKey(const QString& dbPath, QByteArray& data) data = encrypted; if (!cipher.finish(data)) { data.clear(); - m_error = tr("Failed to decrypt key data."); + m_error = QObject::tr("Failed to decrypt key data."); return false; } return true; } -void WindowsHello::reset(const QString& dbPath) +void WindowsHello::reset(const QUuid& dbUuid) { - m_encryptedKeys.remove(dbPath); + m_encryptedKeys.remove(dbUuid); } -bool WindowsHello::hasKey(const QString& dbPath) const +bool WindowsHello::hasKey(const QUuid& dbUuid) const { - return m_encryptedKeys.contains(dbPath); + return m_encryptedKeys.contains(dbUuid); } void WindowsHello::reset() diff --git a/src/winhello/WindowsHello.h b/src/quickunlock/WindowsHello.h similarity index 57% rename from src/winhello/WindowsHello.h rename to src/quickunlock/WindowsHello.h index 5faf7eb256..ea59f91c30 100644 --- a/src/winhello/WindowsHello.h +++ b/src/quickunlock/WindowsHello.h @@ -18,41 +18,28 @@ #ifndef KEEPASSXC_WINDOWSHELLO_H #define KEEPASSXC_WINDOWSHELLO_H +#include "QuickUnlockInterface.h"; + #include #include -class WindowsHello : public QObject +class WindowsHello : public QuickUnlockInterface { - Q_OBJECT - public: - static WindowsHello* instance(); - bool isAvailable() const; - QString errorString() const; - void reset(); - - bool storeKey(const QString& dbPath, const QByteArray& key); - bool getKey(const QString& dbPath, QByteArray& key); - bool hasKey(const QString& dbPath) const; - void reset(const QString& dbPath); + WindowsHello() = default; + bool isAvailable() const override; + QString errorString() const override; + void reset() override; -signals: - void availableChanged(bool state); + bool setKey(const QUuid& dbUuid, const QByteArray& key) override; + bool getKey(const QUuid& dbUuid, QByteArray& key) override; + bool hasKey(const QUuid& dbUuid) const override; + void reset(const QUuid& dbUuid) override; private: - bool m_available = false; QString m_error; - QHash m_encryptedKeys; - - static WindowsHello* m_instance; - WindowsHello(QObject* parent = nullptr); - ~WindowsHello() override = default; + QHash m_encryptedKeys; Q_DISABLE_COPY(WindowsHello); }; -inline WindowsHello* getWindowsHello() -{ - return WindowsHello::instance(); -} - #endif // KEEPASSXC_WINDOWSHELLO_H diff --git a/src/quickunlock/dbus/org.freedesktop.PolicyKit1.Authority.xml b/src/quickunlock/dbus/org.freedesktop.PolicyKit1.Authority.xml new file mode 100644 index 0000000000..d46d71d2a0 --- /dev/null +++ b/src/quickunlock/dbus/org.freedesktop.PolicyKit1.Authority.xml @@ -0,0 +1,16 @@ + + + + + + + + + > + + + + + + + diff --git a/src/touchid/TouchID.h b/src/touchid/TouchID.h deleted file mode 100644 index e32f1fa126..0000000000 --- a/src/touchid/TouchID.h +++ /dev/null @@ -1,39 +0,0 @@ -#ifndef KEEPASSX_TOUCHID_H -#define KEEPASSX_TOUCHID_H - -#include - -class TouchID -{ -public: - static TouchID& getInstance(); - -private: - TouchID() - { - // Nothing to do here - } - -public: - TouchID(TouchID const&) = delete; - void operator=(TouchID const&) = delete; - - bool storeKey(const QString& databasePath, const QByteArray& passwordKey); - bool getKey(const QString& databasePath, QByteArray& passwordKey) const; - bool containsKey(const QString& databasePath) const; - void reset(const QString& databasePath = ""); - - bool isAvailable(); - -private: - static bool isWatchAvailable(); - static bool isTouchIdAvailable(); - - static void deleteKeyEntry(const QString& accountName); - static QString databaseKeyName(const QString& databasePath); - -private: - QHash m_encryptedMasterKeys; -}; - -#endif // KEEPASSX_TOUCHID_H From 6f5f600559de391e53617b6612d7e3ed8bf00b3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sami=20V=C3=A4nttinen?= Date: Tue, 24 Oct 2023 06:08:41 +0300 Subject: [PATCH 5/8] Fix crash on database open from browser (#9939) --- src/browser/BrowserService.cpp | 2 +- src/gui/DatabaseTabWidget.cpp | 9 ++++++++- src/gui/DatabaseTabWidget.h | 3 ++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/browser/BrowserService.cpp b/src/browser/BrowserService.cpp index 406276182d..cff27209a7 100644 --- a/src/browser/BrowserService.cpp +++ b/src/browser/BrowserService.cpp @@ -116,7 +116,7 @@ bool BrowserService::openDatabase(bool triggerUnlock) return true; } - if (triggerUnlock) { + if (triggerUnlock && !m_bringToFrontRequested) { m_bringToFrontRequested = true; updateWindowState(); emit requestUnlock(); diff --git a/src/gui/DatabaseTabWidget.cpp b/src/gui/DatabaseTabWidget.cpp index d4e50116f8..4e422ebb2f 100644 --- a/src/gui/DatabaseTabWidget.cpp +++ b/src/gui/DatabaseTabWidget.cpp @@ -1,5 +1,5 @@ /* - * Copyright (C) 2021 KeePassXC Team + * Copyright (C) 2023 KeePassXC Team * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -41,6 +41,7 @@ DatabaseTabWidget::DatabaseTabWidget(QWidget* parent) , m_dbWidgetStateSync(new DatabaseWidgetStateSync(this)) , m_dbWidgetPendingLock(nullptr) , m_databaseOpenDialog(new DatabaseOpenDialog(this)) + , m_databaseOpenInProgress(false) { auto* tabBar = new QTabBar(this); tabBar->setAcceptDrops(true); @@ -857,6 +858,7 @@ void DatabaseTabWidget::emitDatabaseLockChanged() emit databaseLocked(dbWidget); } else { emit databaseUnlocked(dbWidget); + m_databaseOpenInProgress = false; } } @@ -889,6 +891,11 @@ void DatabaseTabWidget::performGlobalAutoType(const QString& search) void DatabaseTabWidget::performBrowserUnlock() { + if (m_databaseOpenInProgress) { + return; + } + + m_databaseOpenInProgress = true; auto dbWidget = currentDatabaseWidget(); if (dbWidget && dbWidget->isLocked()) { unlockAnyDatabaseInDialog(DatabaseOpenDialog::Intent::Browser); diff --git a/src/gui/DatabaseTabWidget.h b/src/gui/DatabaseTabWidget.h index 3a6791a806..58f5408e3a 100644 --- a/src/gui/DatabaseTabWidget.h +++ b/src/gui/DatabaseTabWidget.h @@ -1,5 +1,5 @@ /* - * Copyright (C) 2021 KeePassXC Team + * Copyright (C) 2023 KeePassXC Team * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -117,6 +117,7 @@ private slots: QPointer m_dbWidgetPendingLock; QPointer m_databaseOpenDialog; QTimer m_lockDelayTimer; + bool m_databaseOpenInProgress; }; #endif // KEEPASSX_DATABASETABWIDGET_H From 029b4c25acbf798fe5fea869be2067b7014bc4ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sami=20V=C3=A4nttinen?= Date: Tue, 24 Oct 2023 06:23:20 +0300 Subject: [PATCH 6/8] Fix terminating KeePassXC processes with MSI installer (#9822) --- share/windows/wix-template.xml | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/share/windows/wix-template.xml b/share/windows/wix-template.xml index ae937ce709..add2af2974 100644 --- a/share/windows/wix-template.xml +++ b/share/windows/wix-template.xml @@ -92,6 +92,9 @@ + + + @@ -116,12 +119,17 @@ - - - + + + + + + + + From 378c2992cd43811fec53e969c978fb710dcd3e01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Barnab=C3=A1s=20P=C5=91cze?= Date: Sun, 8 Oct 2023 20:34:14 +0200 Subject: [PATCH 7/8] Do not hard-code colors in classic stylesheet for SearchBanner/KeeShareBanner Having the green-ish hard-coded color makes the banner stand out too much when the platform native theming is used. --- src/gui/styles/base/classicstyle.qss | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/gui/styles/base/classicstyle.qss b/src/gui/styles/base/classicstyle.qss index f7d3c0fb47..d0ab2b88fc 100644 --- a/src/gui/styles/base/classicstyle.qss +++ b/src/gui/styles/base/classicstyle.qss @@ -9,8 +9,9 @@ QToolTip { DatabaseWidget #SearchBanner, DatabaseWidget #KeeShareBanner { font-weight: bold; - background-color: rgb(94, 161, 14); - border: 1px solid rgb(190, 190, 190); + background-color: palette(highlight); + color: palette(highlighted-text); + border: 1px solid palette(dark); padding: 2px; } From 6f2354c0e94e79b0a87d9f116957df5a509149ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sami=20V=C3=A4nttinen?= Date: Wed, 25 Oct 2023 17:12:55 +0300 Subject: [PATCH 8/8] Add basic support for WebAuthn (Passkeys) (#8825) --------- Co-authored-by: varjolintu Co-authored-by: droidmonkey --- CMakeLists.txt | 8 + COPYING | 3 +- INSTALL.md | 1 + .../application/scalable/actions/passkey.svg | 1 + share/icons/icons.qrc | 1 + share/translations/keepassxc_en.ts | 333 ++++++++++++- src/CMakeLists.txt | 30 +- src/browser/BrowserAccessControlDialog.h | 6 +- src/browser/BrowserAction.cpp | 107 +++- src/browser/BrowserAction.h | 17 +- src/browser/BrowserCbor.cpp | 253 ++++++++++ src/browser/BrowserCbor.h | 70 +++ src/browser/BrowserEntryConfig.h | 6 +- src/browser/BrowserEntrySaveDialog.h | 6 +- src/browser/BrowserEntrySaveDialog.ui | 2 +- src/browser/BrowserHost.h | 6 +- src/browser/BrowserMessageBuilder.cpp | 78 ++- src/browser/BrowserMessageBuilder.h | 22 +- src/browser/BrowserPasskeys.cpp | 465 +++++++++++++++++ src/browser/BrowserPasskeys.h | 143 ++++++ .../BrowserPasskeysConfirmationDialog.cpp | 153 ++++++ .../BrowserPasskeysConfirmationDialog.h | 66 +++ .../BrowserPasskeysConfirmationDialog.ui | 159 ++++++ src/browser/BrowserService.cpp | 273 +++++++++- src/browser/BrowserService.h | 56 ++- src/browser/BrowserSettings.h | 6 +- src/browser/CMakeLists.txt | 11 +- src/config-keepassx.h.cmake | 1 + src/core/EntryAttributes.cpp | 11 +- src/core/EntryAttributes.h | 2 + src/core/Group.cpp | 13 + src/core/Group.h | 1 + src/core/Tools.cpp | 13 + src/core/Tools.h | 1 + src/gui/DatabaseTabWidget.cpp | 12 + src/gui/DatabaseTabWidget.h | 5 + src/gui/DatabaseWidget.cpp | 18 + src/gui/DatabaseWidget.h | 4 + src/gui/MainWindow.cpp | 21 + src/gui/MainWindow.ui | 31 ++ src/gui/passkeys/PasskeyExportDialog.cpp | 91 ++++ src/gui/passkeys/PasskeyExportDialog.h | 52 ++ src/gui/passkeys/PasskeyExportDialog.ui | 121 +++++ src/gui/passkeys/PasskeyExporter.cpp | 105 ++++ src/gui/passkeys/PasskeyExporter.h | 39 ++ src/gui/passkeys/PasskeyImportDialog.cpp | 121 +++++ src/gui/passkeys/PasskeyImportDialog.h | 60 +++ src/gui/passkeys/PasskeyImportDialog.ui | 174 +++++++ src/gui/passkeys/PasskeyImporter.cpp | 139 ++++++ src/gui/passkeys/PasskeyImporter.h | 48 ++ src/gui/reports/ReportsDialog.cpp | 32 +- src/gui/reports/ReportsDialog.h | 11 +- src/gui/reports/ReportsPagePasskeys.cpp | 52 ++ src/gui/reports/ReportsPagePasskeys.h | 40 ++ .../ReportsWidgetBrowserStatistics.cpp | 16 +- .../reports/ReportsWidgetBrowserStatistics.ui | 20 +- src/gui/reports/ReportsWidgetHealthcheck.cpp | 39 +- src/gui/reports/ReportsWidgetHealthcheck.h | 2 +- src/gui/reports/ReportsWidgetHealthcheck.ui | 10 +- src/gui/reports/ReportsWidgetPasskeys.cpp | 294 +++++++++++ src/gui/reports/ReportsWidgetPasskeys.h | 76 +++ src/gui/reports/ReportsWidgetPasskeys.ui | 102 ++++ src/gui/styles/base/basestyle.qss | 4 + tests/CMakeLists.txt | 13 +- tests/TestPasskeys.cpp | 471 ++++++++++++++++++ tests/TestPasskeys.h | 47 ++ 66 files changed, 4457 insertions(+), 137 deletions(-) create mode 100644 share/icons/application/scalable/actions/passkey.svg create mode 100644 src/browser/BrowserCbor.cpp create mode 100644 src/browser/BrowserCbor.h create mode 100644 src/browser/BrowserPasskeys.cpp create mode 100644 src/browser/BrowserPasskeys.h create mode 100644 src/browser/BrowserPasskeysConfirmationDialog.cpp create mode 100644 src/browser/BrowserPasskeysConfirmationDialog.h create mode 100755 src/browser/BrowserPasskeysConfirmationDialog.ui create mode 100644 src/gui/passkeys/PasskeyExportDialog.cpp create mode 100644 src/gui/passkeys/PasskeyExportDialog.h create mode 100755 src/gui/passkeys/PasskeyExportDialog.ui create mode 100644 src/gui/passkeys/PasskeyExporter.cpp create mode 100644 src/gui/passkeys/PasskeyExporter.h create mode 100644 src/gui/passkeys/PasskeyImportDialog.cpp create mode 100644 src/gui/passkeys/PasskeyImportDialog.h create mode 100755 src/gui/passkeys/PasskeyImportDialog.ui create mode 100644 src/gui/passkeys/PasskeyImporter.cpp create mode 100644 src/gui/passkeys/PasskeyImporter.h create mode 100644 src/gui/reports/ReportsPagePasskeys.cpp create mode 100644 src/gui/reports/ReportsPagePasskeys.h create mode 100644 src/gui/reports/ReportsWidgetPasskeys.cpp create mode 100644 src/gui/reports/ReportsWidgetPasskeys.h create mode 100644 src/gui/reports/ReportsWidgetPasskeys.ui create mode 100644 tests/TestPasskeys.cpp create mode 100644 tests/TestPasskeys.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 074c709330..252e61fbd6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -53,6 +53,7 @@ set(WITH_XC_ALL OFF CACHE BOOL "Build in all available plugins") option(WITH_XC_AUTOTYPE "Include Auto-Type." ON) option(WITH_XC_NETWORKING "Include networking code (e.g. for downloading website icons)." OFF) option(WITH_XC_BROWSER "Include browser integration with keepassxc-browser." OFF) +option(WITH_XC_BROWSER_PASSKEYS "Passkeys support for browser integration." OFF) option(WITH_XC_YUBIKEY "Include YubiKey support." OFF) option(WITH_XC_SSHAGENT "Include SSH agent support." OFF) option(WITH_XC_KEESHARE "Sharing integration with KeeShare" OFF) @@ -98,6 +99,7 @@ if(WITH_XC_ALL) set(WITH_XC_AUTOTYPE ON) set(WITH_XC_NETWORKING ON) set(WITH_XC_BROWSER ON) + set(WITH_XC_BROWSER_PASSKEYS ON) set(WITH_XC_YUBIKEY ON) set(WITH_XC_SSHAGENT ON) set(WITH_XC_KEESHARE ON) @@ -514,6 +516,12 @@ if(Qt5Core_VERSION VERSION_LESS "5.2.0") message(FATAL_ERROR "Qt version 5.2.0 or higher is required") endif() +# CBOR for Passkeys requires Qt 5.12 +if(Qt5Core_VERSION VERSION_LESS "5.12.0") + message(STATUS "Qt version 5.12.0 or higher is required for Passkeys support") + set(WITH_XC_BROWSER_PASSKEYS OFF) +endif() + get_filename_component(Qt5_PREFIX ${Qt5_DIR}/../../.. REALPATH) if(APPLE) # Add includes under Qt5 Prefix in case Qt6 is also installed diff --git a/COPYING b/COPYING index 04d4376f02..3da23d212d 100644 --- a/COPYING +++ b/COPYING @@ -1,5 +1,5 @@ KeePassXC - http://www.keepassxc.org/ -Copyright (C) 2016-2020 KeePassXC Team +Copyright (C) 2016-2023 KeePassXC Team This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by @@ -194,6 +194,7 @@ Files: share/icons/application/scalable/actions/application-exit.svg share/icons/application/scalable/actions/object-unlocked.svg share/icons/application/scalable/actions/paperclip.svg share/icons/application/scalable/actions/password-copy.svg + share/icons/application/scalable/actions/passkey.svg share/icons/application/scalable/actions/password-generator.svg share/icons/application/scalable/actions/password-show-off.svg share/icons/application/scalable/actions/password-show-on.svg diff --git a/INSTALL.md b/INSTALL.md index 17bcdae9f1..bc1a4ed5b6 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -112,6 +112,7 @@ KeePassXC comes with a variety of build options that can turn on/off features. M -DWITH_XC_AUTOTYPE=[ON|OFF] Enable/Disable Auto-Type (default: ON) -DWITH_XC_YUBIKEY=[ON|OFF] Enable/Disable YubiKey HMAC-SHA1 authentication support (default: OFF) -DWITH_XC_BROWSER=[ON|OFF] Enable/Disable KeePassXC-Browser extension support (default: OFF) +-DWITH_XC_BROWSER_PASSKEYS=[ON|OFF] Enable/Disable Passkeys support for browser integration (default: OFF) -DWITH_XC_NETWORKING=[ON|OFF] Enable/Disable Networking support (e.g., favicon downloading) (default: OFF) -DWITH_XC_SSHAGENT=[ON|OFF] Enable/Disable SSHAgent support (default: OFF) -DWITH_XC_FDOSECRETS=[ON|OFF] (Linux Only) Enable/Disable Freedesktop.org Secrets Service support (default:OFF) diff --git a/share/icons/application/scalable/actions/passkey.svg b/share/icons/application/scalable/actions/passkey.svg new file mode 100644 index 0000000000..c1345f1f20 --- /dev/null +++ b/share/icons/application/scalable/actions/passkey.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/share/icons/icons.qrc b/share/icons/icons.qrc index 4982e3b0e3..92753125c4 100644 --- a/share/icons/icons.qrc +++ b/share/icons/icons.qrc @@ -59,6 +59,7 @@ application/scalable/actions/object-locked.svg application/scalable/actions/object-unlocked.svg application/scalable/actions/paperclip.svg + application/scalable/actions/passkey.svg application/scalable/actions/password-copy.svg application/scalable/actions/password-generator.svg application/scalable/actions/password-show-off.svg diff --git a/share/translations/keepassxc_en.ts b/share/translations/keepassxc_en.ts index bb55d604d1..52237dd2a1 100644 --- a/share/translations/keepassxc_en.ts +++ b/share/translations/keepassxc_en.ts @@ -827,10 +827,6 @@ Ctrl+4 - Use Virtual Keyboard (Windows Only)</p> BrowserEntrySaveDialog - - KeePassXC-Browser Save Entry - - Ok @@ -844,6 +840,65 @@ Ctrl+4 - Use Virtual Keyboard (Windows Only)</p> Please select the correct database for saving credentials. + + KeePassXC - Select Database + + + + + BrowserPasskeysConfirmationDialog + + KeePassXC: Passkey credentials + + + + Cancel + + + + Update + + + + Authenticate + + + + Register new + + + + Register + + + + Timeout in <b>%n</b> seconds... + + + + + + + Do you want to register Passkey for: + + + + %1 (%2) + + + + Existing Passkey found. +Do you want to register a new Passkey for: + + + + Select the existing Passkey and press Update to replace it. + + + + Authenticate Passkey credentials for: + + BrowserService @@ -900,6 +955,10 @@ Do you want to delete the entry? + + %1 (Passkey) + + BrowserSettingsWidget @@ -5474,6 +5533,18 @@ We recommend you use the AppImage available on our downloads page. Allow Screen Capture + + Passkeys… + + + + Passkeys + + + + Import Passkey + + ManageDatabase @@ -5859,6 +5930,152 @@ We recommend you use the AppImage available on our downloads page. + + PasskeyExportDialog + + KeePassXC - Passkey Export + + + + Export the following Passkey entries. + + + + Filenames will be generated with title and .passkey file extension. + + + + Export entries + + + + Export Selected + + + + Cancel + + + + Export to folder + + + + + PasskeyExporter + + KeePassXC: Passkey Export + + + + File "%1.passkey" already exists. +Do you want to overwrite it? + + + + + Cannot open file + + + + Cannot open file "%1" for writing. + + + + Cannot write to file + + + + + PasskeyImportDialog + + KeePassXC - Passkey Import + + + + Do you want to import the Passkey? + + + + URL: %1 + + + + Username: %1 + + + + Use default group (Imported Passkeys) + + + + Group + + + + Database + + + + Select Database + + + + Import Passkey + + + + Import + + + + Cancel + + + + Database: %1 + + + + Group: + + + + + PasskeyImporter + + Passkey file + + + + All files + + + + Open Passkey file + + + + Cannot open file + + + + Cannot open file "%1" for reading. + + + + Cannot import Passkey + + + + Cannot import Passkey file "%1". Data is missing. + + + + Cannot import Passkey file "%1". Private key is missing or malformed. + + + PasswordEditWidget @@ -8031,6 +8248,10 @@ Kernel: %3 %4 Failed to decrypt key data. + + Passkeys + + QtIOCompressor @@ -8068,18 +8289,6 @@ Kernel: %3 %4 ReportsWidgetBrowserStatistics - - Exclude expired entries from the report - - - - Show only entries which have URL set - - - - Show only entries which have browser settings in custom data - - Double-click entries to edit. @@ -8147,17 +8356,25 @@ Kernel: %3 %4 Exclude from reports - - - ReportsWidgetHealthcheck - Exclude expired entries from the report + Only show entries that have a URL - Also show entries that have been excluded from reports + Only show entries that have been explicitly allowed or denied + + + + Show expired entries + + + + (Expired) + + + ReportsWidgetHealthcheck Hover over reason to show additional details. Double-click entries to edit. @@ -8236,6 +8453,18 @@ Kernel: %3 %4 Exclude from reports + + Show expired entries + + + + Show entries that have been excluded from reports + + + + (Expired) + + ReportsWidgetHibp @@ -8335,6 +8564,68 @@ Kernel: %3 %4 + + ReportsWidgetPasskeys + + Export + + + + Import + + + + List of entry URLs + + + + Please wait, list of entries with Passkeys is being updated… + + + + No entries with Passkeys. + + + + Title + + + + Path + + + + Username + + + + URLs + + + + Edit Entry… + + + + Delete Entry(s)… + + + + + + + Relying Party + + + + Show expired entries + + + + (Expired) + + + ReportsWidgetStatistics diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 298355e8d7..15b6d947ac 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -259,6 +259,7 @@ set(keepassx_SOURCES_MAINEXE main.cpp) add_feature_info(Auto-Type WITH_XC_AUTOTYPE "Automatic password typing") add_feature_info(Networking WITH_XC_NETWORKING "Compile KeePassXC with network access code (e.g. for downloading website icons)") add_feature_info(KeePassXC-Browser WITH_XC_BROWSER "Browser integration with KeePassXC-Browser") +add_feature_info(Passkeys WITH_XC_BROWSER_PASSKEYS "Passkeys support for browser integration") add_feature_info(SSHAgent WITH_XC_SSHAGENT "SSH agent integration compatible with KeeAgent") add_feature_info(KeeShare WITH_XC_KEESHARE "Sharing integration with KeeShare") add_feature_info(YubiKey WITH_XC_YUBIKEY "YubiKey HMAC-SHA1 challenge-response") @@ -271,10 +272,21 @@ add_subdirectory(browser) add_subdirectory(proxy) if(WITH_XC_BROWSER) set(keepassxcbrowser_LIB keepassxcbrowser) - set(keepassx_SOURCES ${keepassx_SOURCES} gui/dbsettings/DatabaseSettingsWidgetBrowser.cpp) - set(keepassx_SOURCES ${keepassx_SOURCES} gui/entry/EntryURLModel.cpp) - set(keepassx_SOURCES ${keepassx_SOURCES} gui/reports/ReportsWidgetBrowserStatistics.cpp) - set(keepassx_SOURCES ${keepassx_SOURCES} gui/reports/ReportsPageBrowserStatistics.cpp) + set(keepassx_SOURCES ${keepassx_SOURCES} + gui/dbsettings/DatabaseSettingsWidgetBrowser.cpp + gui/entry/EntryURLModel.cpp + gui/reports/ReportsWidgetBrowserStatistics.cpp + gui/reports/ReportsPageBrowserStatistics.cpp) +endif() + +if(WITH_XC_BROWSER_PASSKEYS) + set(keepassx_SOURCES ${keepassx_SOURCES} + gui/reports/ReportsWidgetPasskeys.cpp + gui/reports/ReportsPagePasskeys.cpp + gui/passkeys/PasskeyExporter.cpp + gui/passkeys/PasskeyExportDialog.cpp + gui/passkeys/PasskeyImporter.cpp + gui/passkeys/PasskeyImportDialog.cpp) endif() add_subdirectory(autotype) @@ -315,14 +327,14 @@ if(WIN32) endif() if(WITH_XC_YUBIKEY) - list(APPEND keepassx_SOURCES + list(APPEND keepassx_SOURCES keys/drivers/YubiKey.h - keys/drivers/YubiKey.cpp - keys/drivers/YubiKeyInterface.cpp - keys/drivers/YubiKeyInterfaceUSB.cpp + keys/drivers/YubiKey.cpp + keys/drivers/YubiKeyInterface.cpp + keys/drivers/YubiKeyInterfaceUSB.cpp keys/drivers/YubiKeyInterfacePCSC.cpp) else() - list(APPEND keepassx_SOURCES + list(APPEND keepassx_SOURCES keys/drivers/YubiKey.h keys/drivers/YubiKeyStub.cpp) endif() diff --git a/src/browser/BrowserAccessControlDialog.h b/src/browser/BrowserAccessControlDialog.h index 57156fce65..3ecf5b506e 100644 --- a/src/browser/BrowserAccessControlDialog.h +++ b/src/browser/BrowserAccessControlDialog.h @@ -16,8 +16,8 @@ * along with this program. If not, see . */ -#ifndef BROWSERACCESSCONTROLDIALOG_H -#define BROWSERACCESSCONTROLDIALOG_H +#ifndef KEEPASSXC_BROWSERACCESSCONTROLDIALOG_H +#define KEEPASSXC_BROWSERACCESSCONTROLDIALOG_H #include #include @@ -64,4 +64,4 @@ private slots: QList m_entriesToConfirm; }; -#endif // BROWSERACCESSCONTROLDIALOG_H +#endif // KEEPASSXC_BROWSERACCESSCONTROLDIALOG_H diff --git a/src/browser/BrowserAction.cpp b/src/browser/BrowserAction.cpp index 79ff82c571..8fe2244490 100644 --- a/src/browser/BrowserAction.cpp +++ b/src/browser/BrowserAction.cpp @@ -16,7 +16,10 @@ */ #include "BrowserAction.h" -#include "BrowserService.h" +#include "BrowserMessageBuilder.h" +#ifdef WITH_XC_BROWSER_PASSKEYS +#include "BrowserPasskeys.h" +#endif #include "BrowserSettings.h" #include "core/Global.h" #include "core/Tools.h" @@ -36,6 +39,8 @@ static const QString BROWSER_REQUEST_GET_DATABASE_GROUPS = QStringLiteral("get-d static const QString BROWSER_REQUEST_GET_LOGINS = QStringLiteral("get-logins"); static const QString BROWSER_REQUEST_GET_TOTP = QStringLiteral("get-totp"); static const QString BROWSER_REQUEST_LOCK_DATABASE = QStringLiteral("lock-database"); +static const QString BROWSER_REQUEST_PASSKEYS_GET = QStringLiteral("passkeys-get"); +static const QString BROWSER_REQUEST_PASSKEYS_REGISTER = QStringLiteral("passkeys-register"); static const QString BROWSER_REQUEST_REQUEST_AUTOTYPE = QStringLiteral("request-autotype"); static const QString BROWSER_REQUEST_SET_LOGIN = QStringLiteral("set-login"); static const QString BROWSER_REQUEST_TEST_ASSOCIATE = QStringLiteral("test-associate"); @@ -104,6 +109,12 @@ QJsonObject BrowserAction::handleAction(QLocalSocket* socket, const QJsonObject& return handleGlobalAutoType(json, action); } else if (action.compare("get-database-entries", Qt::CaseSensitive) == 0) { return handleGetDatabaseEntries(json, action); +#ifdef WITH_XC_BROWSER_PASSKEYS + } else if (action.compare(BROWSER_REQUEST_PASSKEYS_GET) == 0) { + return handlePasskeysGet(json, action); + } else if (action.compare(BROWSER_REQUEST_PASSKEYS_REGISTER) == 0) { + return handlePasskeysRegister(json, action); +#endif } // Action was not recognized @@ -226,18 +237,11 @@ QJsonObject BrowserAction::handleGetLogins(const QJsonObject& json, const QStrin return getErrorReply(action, ERROR_KEEPASS_NO_URL_PROVIDED); } - const auto keys = browserRequest.getArray("keys"); - - StringPairList keyList; - for (const auto val : keys) { - const auto keyObject = val.toObject(); - keyList.push_back(qMakePair(keyObject.value("id").toString(), keyObject.value("key").toString())); - } - const auto id = browserRequest.getString("id"); const auto formUrl = browserRequest.getString("submitUrl"); const auto auth = browserRequest.getString("httpAuth"); const bool httpAuth = auth.compare(TRUE_STR) == 0; + const auto keyList = getConnectionKeys(browserRequest); EntryParameters entryParameters; entryParameters.dbid = id; @@ -384,10 +388,6 @@ QJsonObject BrowserAction::handleGetDatabaseGroups(const QJsonObject& json, cons QJsonObject BrowserAction::handleGetDatabaseEntries(const QJsonObject& json, const QString& action) { - const QString hash = browserService()->getDatabaseHash(); - const QString nonce = json.value("nonce").toString(); - const QString encrypted = json.value("message").toString(); - if (!m_associated) { return getErrorReply(action, ERROR_KEEPASS_ASSOCIATION_FAILED); } @@ -516,6 +516,74 @@ QJsonObject BrowserAction::handleGlobalAutoType(const QJsonObject& json, const Q return buildResponse(action, browserRequest.incrementedNonce); } +#ifdef WITH_XC_BROWSER_PASSKEYS +QJsonObject BrowserAction::handlePasskeysGet(const QJsonObject& json, const QString& action) +{ + if (!m_associated) { + return getErrorReply(action, ERROR_KEEPASS_ASSOCIATION_FAILED); + } + + const auto browserRequest = decodeRequest(json); + if (browserRequest.isEmpty()) { + return getErrorReply(action, ERROR_KEEPASS_CANNOT_DECRYPT_MESSAGE); + } + + const auto command = browserRequest.getString("action"); + if (command.isEmpty() || command.compare(BROWSER_REQUEST_PASSKEYS_GET) != 0) { + return getErrorReply(action, ERROR_KEEPASS_INCORRECT_ACTION); + } + + const auto publicKey = browserRequest.getObject("publicKey"); + if (publicKey.isEmpty()) { + return getErrorReply(action, ERROR_PASSKEYS_EMPTY_PUBLIC_KEY); + } + + const auto origin = browserRequest.getString("origin"); + if (!origin.startsWith("https://")) { + return getErrorReply(action, ERROR_PASSKEYS_INVALID_URL_PROVIDED); + } + + const auto keyList = getConnectionKeys(browserRequest); + const auto response = browserService()->showPasskeysAuthenticationPrompt(publicKey, origin, keyList); + + const Parameters params{{"response", response}}; + return buildResponse(action, browserRequest.incrementedNonce, params); +} + +QJsonObject BrowserAction::handlePasskeysRegister(const QJsonObject& json, const QString& action) +{ + if (!m_associated) { + return getErrorReply(action, ERROR_KEEPASS_ASSOCIATION_FAILED); + } + + const auto browserRequest = decodeRequest(json); + if (browserRequest.isEmpty()) { + return getErrorReply(action, ERROR_KEEPASS_CANNOT_DECRYPT_MESSAGE); + } + + const auto command = browserRequest.getString("action"); + if (command.isEmpty() || command.compare(BROWSER_REQUEST_PASSKEYS_REGISTER) != 0) { + return getErrorReply(action, ERROR_KEEPASS_INCORRECT_ACTION); + } + + const auto publicKey = browserRequest.getObject("publicKey"); + if (publicKey.isEmpty()) { + return getErrorReply(action, ERROR_PASSKEYS_EMPTY_PUBLIC_KEY); + } + + const auto origin = browserRequest.getString("origin"); + if (!origin.startsWith("https://")) { + return getErrorReply(action, ERROR_KEEPASS_ACTION_CANCELLED_OR_DENIED); + } + + const auto keyList = getConnectionKeys(browserRequest); + const auto response = browserService()->showPasskeysRegisterPrompt(publicKey, origin, keyList); + + const Parameters params{{"response", response}}; + return buildResponse(action, browserRequest.incrementedNonce, params); +} +#endif + QJsonObject BrowserAction::decryptMessage(const QString& message, const QString& nonce) { return browserMessageBuilder()->decryptMessage(message, nonce, m_clientPublicKey, m_secretKey); @@ -541,3 +609,16 @@ BrowserRequest BrowserAction::decodeRequest(const QJsonObject& json) browserMessageBuilder()->incrementNonce(nonce), decryptMessage(encrypted, nonce)}; } + +StringPairList BrowserAction::getConnectionKeys(const BrowserRequest& browserRequest) +{ + const auto keys = browserRequest.getArray("keys"); + + StringPairList keyList; + for (const auto val : keys) { + const auto keyObject = val.toObject(); + keyList.push_back(qMakePair(keyObject.value("id").toString(), keyObject.value("key").toString())); + } + + return keyList; +} diff --git a/src/browser/BrowserAction.h b/src/browser/BrowserAction.h index fe65c977a9..a493073d69 100644 --- a/src/browser/BrowserAction.h +++ b/src/browser/BrowserAction.h @@ -15,10 +15,11 @@ * along with this program. If not, see . */ -#ifndef BROWSERACTION_H -#define BROWSERACTION_H +#ifndef KEEPASSXC_BROWSERACTION_H +#define KEEPASSXC_BROWSERACTION_H #include "BrowserMessageBuilder.h" +#include "BrowserService.h" #include #include @@ -43,6 +44,11 @@ struct BrowserRequest return decrypted.value(param).toArray(); } + inline QJsonObject getObject(const QString& param) const + { + return decrypted.value(param).toObject(); + } + inline QString getString(const QString& param) const { return decrypted.value(param).toString(); @@ -73,12 +79,17 @@ class BrowserAction QJsonObject handleGetTotp(const QJsonObject& json, const QString& action); QJsonObject handleDeleteEntry(const QJsonObject& json, const QString& action); QJsonObject handleGlobalAutoType(const QJsonObject& json, const QString& action); +#ifdef WITH_XC_BROWSER_PASSKEYS + QJsonObject handlePasskeysGet(const QJsonObject& json, const QString& action); + QJsonObject handlePasskeysRegister(const QJsonObject& json, const QString& action); +#endif private: QJsonObject buildResponse(const QString& action, const QString& nonce, const Parameters& params = {}); QJsonObject getErrorReply(const QString& action, const int errorCode) const; QJsonObject decryptMessage(const QString& message, const QString& nonce); BrowserRequest decodeRequest(const QJsonObject& json); + StringPairList getConnectionKeys(const BrowserRequest& browserRequest); private: static const int MaxUrlLength; @@ -91,4 +102,4 @@ class BrowserAction friend class TestBrowser; }; -#endif // BROWSERACTION_H +#endif // KEEPASSXC_BROWSERACTION_H diff --git a/src/browser/BrowserCbor.cpp b/src/browser/BrowserCbor.cpp new file mode 100644 index 0000000000..bcc7043ce0 --- /dev/null +++ b/src/browser/BrowserCbor.cpp @@ -0,0 +1,253 @@ +/* + * Copyright (C) 2023 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "BrowserCbor.h" +#include "BrowserMessageBuilder.h" +#include +#include +#include + +// https://w3c.github.io/webauthn/#sctn-none-attestation +// https://w3c.github.io/webauthn/#sctn-generating-an-attestation-object +QByteArray BrowserCbor::cborEncodeAttestation(const QByteArray& authData) const +{ + QByteArray result; + QCborStreamWriter writer(&result); + + writer.startMap(3); + + writer.append("fmt"); + writer.append("none"); + + writer.append("attStmt"); + writer.startMap(0); + writer.endMap(); + + writer.append("authData"); + writer.appendByteString(authData.constData(), authData.size()); + + writer.endMap(); + + return result; +} + +// https://w3c.github.io/webauthn/#authdata-attestedcredentialdata-credentialpublickey +QByteArray BrowserCbor::cborEncodePublicKey(int alg, const QByteArray& first, const QByteArray& second) const +{ + QByteArray result; + QCborStreamWriter writer(&result); + + if (alg == WebAuthnAlgorithms::ES256) { + writer.startMap(5); + + // Key type + writer.append(1); + writer.append(getCoseKeyType(alg)); + + // Signature algorithm + writer.append(3); + writer.append(alg); + + // Curve parameter + writer.append(-1); + writer.append(getCurveParameter(alg)); + + // Key x-coordinate + writer.append(-2); + writer.append(first); + + // Key y-coordinate + writer.append(-3); + writer.append(second); + + writer.endMap(); + } else if (alg == WebAuthnAlgorithms::RS256) { + writer.startMap(4); + + // Key type + writer.append(1); + writer.append(getCoseKeyType(alg)); + + // Signature algorithm + writer.append(3); + writer.append(alg); + + // Key modulus + writer.append(-1); + writer.append(first); + + // Key exponent + writer.append(-2); + writer.append(second); + + writer.endMap(); + } else if (alg == WebAuthnAlgorithms::EDDSA) { + // https://www.rfc-editor.org/rfc/rfc8152#section-13.2 + writer.startMap(3); + + // Curve parameter + writer.append(-1); + writer.append(getCurveParameter(alg)); + + // Public key + writer.append(-2); + writer.append(first); + + // Private key + writer.append(-4); + writer.append(second); + + writer.endMap(); + } + + return result; +} + +// See: https://fidoalliance.org/specs/common-specs/fido-registry-v2.1-ps-20191217.html#user-verification-methods +QByteArray BrowserCbor::cborEncodeExtensionData(const QJsonObject& extensions) const +{ + if (extensions.empty()) { + return {}; + } + + QByteArray result; + QCborStreamWriter writer(&result); + + writer.startMap(extensions.keys().count()); + if (extensions["credProps"].toBool()) { + writer.append("credProps"); + writer.startMap(1); + writer.append("rk"); + writer.append(true); + writer.endMap(); + } + + if (extensions["uvm"].toBool()) { + writer.append("uvm"); + + writer.startArray(1); + writer.startArray(3); + + // userVerificationMethod (USER_VERIFY_PRESENCE_INTERNAL "presence_internal", 0x00000001) + writer.append(quint32(1)); + + // keyProtectionType (KEY_PROTECTION_SOFTWARE "software", 0x0001) + writer.append(quint16(1)); + + // matcherProtectionType (MATCHER_PROTECTION_SOFTWARE "software", 0x0001) + writer.append(quint16(1)); + + writer.endArray(); + writer.endArray(); + } + writer.endMap(); + + return result; +} + +QJsonObject BrowserCbor::getJsonFromCborData(const QByteArray& byteArray) const +{ + auto reader = QCborStreamReader(byteArray); + auto contents = QCborValue::fromCbor(reader); + if (reader.lastError()) { + return {}; + } + + const auto ret = handleCborValue(contents); + + // Parse variant result to QJsonDocument + const auto jsonDocument = QJsonDocument::fromVariant(ret); + if (jsonDocument.isNull() || jsonDocument.isEmpty()) { + return {}; + } + + return jsonDocument.object(); +} + +QVariant BrowserCbor::handleCborArray(const QCborArray& array) const +{ + QVariantList result; + result.reserve(array.size()); + + for (auto a : array) { + result.append(handleCborValue(a)); + } + + return result; +} + +QVariant BrowserCbor::handleCborMap(const QCborMap& map) const +{ + QVariantMap result; + for (auto pair : map) { + result.insert(handleCborValue(pair.first).toString(), handleCborValue(pair.second)); + } + + return QVariant::fromValue(result); +} + +QVariant BrowserCbor::handleCborValue(const QCborValue& value) const +{ + if (value.isArray()) { + return handleCborArray(value.toArray()); + } else if (value.isMap()) { + return handleCborMap(value.toMap()); + } else if (value.isByteArray()) { + auto ba = value.toByteArray(); + + // Return base64 instead of raw byte array + auto base64Str = browserMessageBuilder()->getBase64FromArray(ba); + return QVariant::fromValue(base64Str); + } + + return value.toVariant(); +} + +// https://www.rfc-editor.org/rfc/rfc8152#section-13.1 +unsigned int BrowserCbor::getCurveParameter(int alg) const +{ + switch (alg) { + case WebAuthnAlgorithms::ES256: + return WebAuthnCurveKey::P256; + case WebAuthnAlgorithms::ES384: + return WebAuthnCurveKey::P384; + case WebAuthnAlgorithms::ES512: + return WebAuthnCurveKey::P521; + case WebAuthnAlgorithms::EDDSA: + return WebAuthnCurveKey::ED25519; + default: + return WebAuthnCurveKey::P256; + } +} + +// See: https://www.rfc-editor.org/rfc/rfc8152 +// AES/HMAC/ChaCha20 etc. carries symmetric keys (4) and OKP not supported currently. +unsigned int BrowserCbor::getCoseKeyType(int alg) const +{ + switch (alg) { + case WebAuthnAlgorithms::ES256: + case WebAuthnAlgorithms::ES384: + case WebAuthnAlgorithms::ES512: + return WebAuthnCoseKeyType::EC2; + case WebAuthnAlgorithms::EDDSA: + return WebAuthnCoseKeyType::OKP; + case WebAuthnAlgorithms::RS256: + return WebAuthnCoseKeyType::RSA; + default: + return WebAuthnCoseKeyType::EC2; + } +} diff --git a/src/browser/BrowserCbor.h b/src/browser/BrowserCbor.h new file mode 100644 index 0000000000..9fcb685335 --- /dev/null +++ b/src/browser/BrowserCbor.h @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2023 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEEPASSXC_BROWSERCBOR_H +#define KEEPASSXC_BROWSERCBOR_H + +#include +#include +#include +#include + +enum WebAuthnAlgorithms : int +{ + ES256 = -7, + EDDSA = -8, + ES384 = -35, + ES512 = -36, + RS256 = -257 +}; + +// https://www.rfc-editor.org/rfc/rfc9053#section-7.1 +enum WebAuthnCurveKey : int +{ + P256 = 1, // EC2, NIST P-256, also known as secp256r1 + P384 = 2, // EC2, NIST P-384, also known as secp384r1 + P521 = 3, // EC2, NIST P-521, also known as secp521r1 + X25519 = 4, // OKP, X25519 for use w/ ECDH only + X448 = 5, // OKP, X448 for use w/ ECDH only + ED25519 = 6, // OKP, Ed25519 for use w/ EdDSA only + ED448 = 7 // OKP, Ed448 for use w/ EdDSA only +}; + +// https://www.rfc-editor.org/rfc/rfc8152 +// For RSA: https://www.rfc-editor.org/rfc/rfc8230#section-4 +enum WebAuthnCoseKeyType : int +{ + OKP = 1, // Octet Keypair + EC2 = 2, // Elliptic Curve + RSA = 3 // RSA +}; + +class BrowserCbor +{ +public: + QByteArray cborEncodeAttestation(const QByteArray& authData) const; + QByteArray cborEncodePublicKey(int alg, const QByteArray& first, const QByteArray& second) const; + QByteArray cborEncodeExtensionData(const QJsonObject& extensions) const; + QJsonObject getJsonFromCborData(const QByteArray& byteArray) const; + QVariant handleCborArray(const QCborArray& array) const; + QVariant handleCborMap(const QCborMap& map) const; + QVariant handleCborValue(const QCborValue& value) const; + unsigned int getCoseKeyType(int alg) const; + unsigned int getCurveParameter(int alg) const; +}; + +#endif // KEEPASSXC_BROWSERCBOR_H diff --git a/src/browser/BrowserEntryConfig.h b/src/browser/BrowserEntryConfig.h index 6de4b0bc5c..2cb76d5f53 100644 --- a/src/browser/BrowserEntryConfig.h +++ b/src/browser/BrowserEntryConfig.h @@ -16,8 +16,8 @@ * along with this program. If not, see . */ -#ifndef BROWSERENTRYCONFIG_H -#define BROWSERENTRYCONFIG_H +#ifndef KEEPASSXC_BROWSERENTRYCONFIG_H +#define KEEPASSXC_BROWSERENTRYCONFIG_H #include #include @@ -55,4 +55,4 @@ class BrowserEntryConfig : public QObject QString m_realm; }; -#endif // BROWSERENTRYCONFIG_H +#endif // KEEPASSXC_BROWSERENTRYCONFIG_H diff --git a/src/browser/BrowserEntrySaveDialog.h b/src/browser/BrowserEntrySaveDialog.h index 8675e36faa..44b3d6601f 100644 --- a/src/browser/BrowserEntrySaveDialog.h +++ b/src/browser/BrowserEntrySaveDialog.h @@ -16,8 +16,8 @@ * along with this program. If not, see . */ -#ifndef BROWSERENTRYSAVEDIALOG_H -#define BROWSERENTRYSAVEDIALOG_H +#ifndef KEEPASSXC_BROWSERENTRYSAVEDIALOG_H +#define KEEPASSXC_BROWSERENTRYSAVEDIALOG_H #include "gui/DatabaseTabWidget.h" @@ -45,4 +45,4 @@ class BrowserEntrySaveDialog : public QDialog QScopedPointer m_ui; }; -#endif // BROWSERENTRYSAVEDIALOG_H +#endif // KEEPASSXC_BROWSERENTRYSAVEDIALOG_H diff --git a/src/browser/BrowserEntrySaveDialog.ui b/src/browser/BrowserEntrySaveDialog.ui index 5beb6fab8f..2928f70ada 100755 --- a/src/browser/BrowserEntrySaveDialog.ui +++ b/src/browser/BrowserEntrySaveDialog.ui @@ -11,7 +11,7 @@ - KeePassXC-Browser Save Entry + KeePassXC - Select Database diff --git a/src/browser/BrowserHost.h b/src/browser/BrowserHost.h index 86f20f1e2d..f3620c04cc 100644 --- a/src/browser/BrowserHost.h +++ b/src/browser/BrowserHost.h @@ -15,8 +15,8 @@ * along with this program. If not, see . */ -#ifndef NATIVEMESSAGINGHOST_H -#define NATIVEMESSAGINGHOST_H +#ifndef KEEPASSXC_NATIVEMESSAGINGHOST_H +#define KEEPASSXC_NATIVEMESSAGINGHOST_H #include #include @@ -56,4 +56,4 @@ private slots: QList m_socketList; }; -#endif // NATIVEMESSAGINGHOST_H +#endif // KEEPASSXC_NATIVEMESSAGINGHOST_H diff --git a/src/browser/BrowserMessageBuilder.cpp b/src/browser/BrowserMessageBuilder.cpp index efbaf8cc23..583b9f33e5 100644 --- a/src/browser/BrowserMessageBuilder.cpp +++ b/src/browser/BrowserMessageBuilder.cpp @@ -19,11 +19,14 @@ #include "BrowserShared.h" #include "config-keepassx.h" #include "core/Global.h" -#include "core/Tools.h" +#include #include #include #include +#ifdef QT_DEBUG +#include +#endif #include @@ -243,6 +246,11 @@ QJsonObject BrowserMessageBuilder::getJsonObject(const uchar* pArray, const uint QByteArray arr = getQByteArray(pArray, len); QJsonParseError err; QJsonDocument doc(QJsonDocument::fromJson(arr, &err)); +#ifdef QT_DEBUG + if (doc.isNull()) { + qWarning() << "Cannot create QJsonDocument: " << err.errorString(); + } +#endif return doc.object(); } @@ -250,6 +258,12 @@ QJsonObject BrowserMessageBuilder::getJsonObject(const QByteArray& ba) const { QJsonParseError err; QJsonDocument doc(QJsonDocument::fromJson(ba, &err)); +#ifdef QT_DEBUG + if (doc.isNull()) { + qWarning() << "Cannot create QJsonDocument: " << err.errorString(); + } +#endif + return doc.object(); } @@ -266,3 +280,65 @@ QString BrowserMessageBuilder::incrementNonce(const QString& nonce) sodium_increment(n.data(), n.size()); return getQByteArray(n.data(), n.size()).toBase64(); } + +QString BrowserMessageBuilder::getRandomBytesAsBase64(int bytes) const +{ + if (bytes == 0) { + return {}; + } + + std::shared_ptr buf(new unsigned char[bytes]); + Botan::Sodium::randombytes_buf(buf.get(), bytes); + + return getBase64FromArray(reinterpret_cast(buf.get()), bytes); +} + +QString BrowserMessageBuilder::getBase64FromArray(const char* arr, int len) const +{ + if (len < 1) { + return {}; + } + + auto data = QByteArray::fromRawData(arr, len); + return getBase64FromArray(data); +} + +// Returns URL encoded base64 with trailing removed +QString BrowserMessageBuilder::getBase64FromArray(const QByteArray& byteArray) const +{ + if (byteArray.length() < 1) { + return {}; + } + + return byteArray.toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals); +} + +QString BrowserMessageBuilder::getBase64FromJson(const QJsonObject& jsonObject) const +{ + if (jsonObject.isEmpty()) { + return {}; + } + + const auto dataArray = QJsonDocument(jsonObject).toJson(QJsonDocument::Compact); + return getBase64FromArray(dataArray); +} + +QByteArray BrowserMessageBuilder::getArrayFromHexString(const QString& hexString) const +{ + return QByteArray::fromHex(hexString.toUtf8()); +} + +QByteArray BrowserMessageBuilder::getArrayFromBase64(const QString& base64str) const +{ + return QByteArray::fromBase64(base64str.toUtf8(), QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals); +} + +QByteArray BrowserMessageBuilder::getSha256Hash(const QString& str) const +{ + return QCryptographicHash::hash(str.toUtf8(), QCryptographicHash::Sha256); +} + +QString BrowserMessageBuilder::getSha256HashAsBase64(const QString& str) const +{ + return getBase64FromArray(QCryptographicHash::hash(str.toUtf8(), QCryptographicHash::Sha256)); +} diff --git a/src/browser/BrowserMessageBuilder.h b/src/browser/BrowserMessageBuilder.h index 1248522afd..b9e172380b 100644 --- a/src/browser/BrowserMessageBuilder.h +++ b/src/browser/BrowserMessageBuilder.h @@ -15,8 +15,8 @@ * along with this program. If not, see . */ -#ifndef BROWSERMESSAGEBUILDER_H -#define BROWSERMESSAGEBUILDER_H +#ifndef KEEPASSXC_BROWSERMESSAGEBUILDER_H +#define KEEPASSXC_BROWSERMESSAGEBUILDER_H #include #include @@ -48,7 +48,13 @@ namespace ERROR_KEEPASS_NO_GROUPS_FOUND = 16, ERROR_KEEPASS_CANNOT_CREATE_NEW_GROUP = 17, ERROR_KEEPASS_NO_VALID_UUID_PROVIDED = 18, - ERROR_KEEPASS_ACCESS_TO_ALL_ENTRIES_DENIED = 19 + ERROR_KEEPASS_ACCESS_TO_ALL_ENTRIES_DENIED = 19, + ERROR_PASSKEYS_ATTESTATION_NOT_SUPPORTED = 20, + ERROR_PASSKEYS_CREDENTIAL_IS_EXCLUDED = 21, + ERROR_PASSKEYS_REQUEST_CANCELED = 22, + ERROR_PASSKEYS_INVALID_USER_VERIFICATION = 23, + ERROR_PASSKEYS_EMPTY_PUBLIC_KEY = 24, + ERROR_PASSKEYS_INVALID_URL_PROVIDED = 25 }; } @@ -84,6 +90,14 @@ class BrowserMessageBuilder QJsonObject getJsonObject(const QByteArray& ba) const; QByteArray base64Decode(const QString& str); QString incrementNonce(const QString& nonce); + QString getRandomBytesAsBase64(int bytes) const; + QString getBase64FromArray(const char* arr, int len) const; + QString getBase64FromArray(const QByteArray& byteArray) const; + QString getBase64FromJson(const QJsonObject& jsonObject) const; + QByteArray getArrayFromHexString(const QString& hexString) const; + QByteArray getArrayFromBase64(const QString& base64str) const; + QByteArray getSha256Hash(const QString& str) const; + QString getSha256HashAsBase64(const QString& str) const; private: Q_DISABLE_COPY(BrowserMessageBuilder); @@ -96,4 +110,4 @@ static inline BrowserMessageBuilder* browserMessageBuilder() return BrowserMessageBuilder::instance(); } -#endif // BROWSERMESSAGEBUILDER_H +#endif // KEEPASSXC_BROWSERMESSAGEBUILDER_H diff --git a/src/browser/BrowserPasskeys.cpp b/src/browser/BrowserPasskeys.cpp new file mode 100644 index 0000000000..4df78b1fe1 --- /dev/null +++ b/src/browser/BrowserPasskeys.cpp @@ -0,0 +1,465 @@ +/* + * Copyright (C) 2023 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "BrowserPasskeys.h" +#include "BrowserMessageBuilder.h" +#include "BrowserService.h" +#include "crypto/Random.h" +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +Q_GLOBAL_STATIC(BrowserPasskeys, s_browserPasskeys); + +const QString BrowserPasskeys::PUBLIC_KEY = QStringLiteral("public-key"); +const QString BrowserPasskeys::REQUIREMENT_DISCOURAGED = QStringLiteral("discouraged"); +const QString BrowserPasskeys::REQUIREMENT_PREFERRED = QStringLiteral("preferred"); +const QString BrowserPasskeys::REQUIREMENT_REQUIRED = QStringLiteral("required"); + +const QString BrowserPasskeys::PASSKEYS_ATTESTATION_DIRECT = QStringLiteral("direct"); +const QString BrowserPasskeys::PASSKEYS_ATTESTATION_NONE = QStringLiteral("none"); + +const QString BrowserPasskeys::KPEX_PASSKEY_USERNAME = QStringLiteral("KPEX_PASSKEY_USERNAME"); +const QString BrowserPasskeys::KPEX_PASSKEY_GENERATED_USER_ID = QStringLiteral("KPEX_PASSKEY_GENERATED_USER_ID"); +const QString BrowserPasskeys::KPEX_PASSKEY_PRIVATE_KEY_PEM = QStringLiteral("KPEX_PASSKEY_PRIVATE_KEY_PEM"); +const QString BrowserPasskeys::KPEX_PASSKEY_RELYING_PARTY = QStringLiteral("KPEX_PASSKEY_RELYING_PARTY"); +const QString BrowserPasskeys::KPEX_PASSKEY_USER_HANDLE = QStringLiteral("KPEX_PASSKEY_USER_HANDLE"); + +BrowserPasskeys* BrowserPasskeys::instance() +{ + return s_browserPasskeys; +} + +PublicKeyCredential BrowserPasskeys::buildRegisterPublicKeyCredential(const QJsonObject& publicKeyCredentialOptions, + const QString& origin, + const TestingVariables& testingVariables) +{ + QJsonObject publicKeyCredential; + const auto id = testingVariables.credentialId.isEmpty() ? browserMessageBuilder()->getRandomBytesAsBase64(ID_BYTES) + : testingVariables.credentialId; + + // Extensions + auto extensionObject = publicKeyCredentialOptions["extensions"].toObject(); + const auto extensionData = buildExtensionData(extensionObject); + const auto extensions = browserMessageBuilder()->getBase64FromArray(extensionData); + + // Response + QJsonObject responseObject; + const auto clientData = buildClientDataJson(publicKeyCredentialOptions, origin, false); + const auto attestationObject = buildAttestationObject(publicKeyCredentialOptions, extensions, id, testingVariables); + responseObject["clientDataJSON"] = browserMessageBuilder()->getBase64FromJson(clientData); + responseObject["attestationObject"] = browserMessageBuilder()->getBase64FromArray(attestationObject.cborEncoded); + + // PublicKeyCredential + publicKeyCredential["authenticatorAttachment"] = QString("platform"); + publicKeyCredential["id"] = id; + publicKeyCredential["response"] = responseObject; + publicKeyCredential["type"] = PUBLIC_KEY; + + return {id, publicKeyCredential, attestationObject.pem}; +} + +QJsonObject BrowserPasskeys::buildGetPublicKeyCredential(const QJsonObject& publicKeyCredentialRequestOptions, + const QString& origin, + const QString& userId, + const QString& userHandle, + const QString& privateKeyPem) +{ + const auto authenticatorData = buildGetAttestationObject(publicKeyCredentialRequestOptions); + const auto clientData = buildClientDataJson(publicKeyCredentialRequestOptions, origin, true); + const auto clientDataArray = QJsonDocument(clientData).toJson(QJsonDocument::Compact); + const auto signature = buildSignature(authenticatorData, clientDataArray, privateKeyPem); + + QJsonObject responseObject; + responseObject["authenticatorData"] = browserMessageBuilder()->getBase64FromArray(authenticatorData); + responseObject["clientDataJSON"] = browserMessageBuilder()->getBase64FromArray(clientDataArray); + responseObject["signature"] = browserMessageBuilder()->getBase64FromArray(signature); + responseObject["userHandle"] = userHandle; + + QJsonObject publicKeyCredential; + publicKeyCredential["authenticatorAttachment"] = QString("platform"); + publicKeyCredential["id"] = userId; + publicKeyCredential["response"] = responseObject; + publicKeyCredential["type"] = PUBLIC_KEY; + + return publicKeyCredential; +} + +bool BrowserPasskeys::isUserVerificationValid(const QString& userVerification) const +{ + return QStringList({REQUIREMENT_PREFERRED, REQUIREMENT_REQUIRED, REQUIREMENT_DISCOURAGED}) + .contains(userVerification); +} + +// See https://w3c.github.io/webauthn/#sctn-createCredential for default timeout values when not set in the request +int BrowserPasskeys::getTimeout(const QString& userVerification, int timeout) const +{ + if (timeout == 0) { + return userVerification == REQUIREMENT_DISCOURAGED ? DEFAULT_DISCOURAGED_TIMEOUT : DEFAULT_TIMEOUT; + } + + return timeout; +} + +QStringList BrowserPasskeys::getAllowedCredentialsFromPublicKey(const QJsonObject& publicKey) const +{ + QStringList allowedCredentials; + for (const auto& cred : publicKey["allowCredentials"].toArray()) { + const auto c = cred.toObject(); + const auto id = c["id"].toString(); + + if (c["type"].toString() == PUBLIC_KEY && !id.isEmpty()) { + allowedCredentials << id; + } + } + + return allowedCredentials; +} + +QJsonObject BrowserPasskeys::buildClientDataJson(const QJsonObject& publicKey, const QString& origin, bool get) +{ + QJsonObject clientData; + clientData["challenge"] = publicKey["challenge"]; + clientData["crossOrigin"] = false; + clientData["origin"] = origin; + clientData["type"] = get ? QString("webauthn.get") : QString("webauthn.create"); + + return clientData; +} + +// https://w3c.github.io/webauthn/#attestation-object +PrivateKey BrowserPasskeys::buildAttestationObject(const QJsonObject& publicKey, + const QString& extensions, + const QString& id, + const TestingVariables& testingVariables) +{ + QByteArray result; + + // Create SHA256 hash from rpId + const auto rpIdHash = browserMessageBuilder()->getSha256Hash(publicKey["rp"]["id"].toString()); + result.append(rpIdHash); + + // Use default flags + const auto flags = + setFlagsFromJson(QJsonObject({{"ED", !extensions.isEmpty()}, + {"AT", true}, + {"BS", false}, + {"BE", false}, + {"UV", publicKey["userVerification"].toString() != REQUIREMENT_DISCOURAGED}, + {"UP", true}})); + result.append(flags); + + // Signature counter (not supported, always 0 + const char counter[4] = {0x00, 0x00, 0x00, 0x00}; + result.append(QByteArray::fromRawData(counter, 4)); + + // AAGUID (use the default/non-set) + result.append("\x01\x02\x03\x04\x05\x06\x07\b\x01\x02\x03\x04\x05\x06\x07\b"); + + // Credential length + const char credentialLength[2] = {0x00, 0x20}; + result.append(QByteArray::fromRawData(credentialLength, 2)); + + // Credential Id + result.append(QByteArray::fromBase64( + testingVariables.credentialId.isEmpty() ? id.toUtf8() : testingVariables.credentialId.toUtf8(), + QByteArray::Base64UrlEncoding)); + + // Credential private key + const auto alg = getAlgorithmFromPublicKey(publicKey); + const auto credentialPublicKey = buildCredentialPrivateKey(alg, testingVariables.first, testingVariables.second); + result.append(credentialPublicKey.cborEncoded); + + // Add extension data if available + if (!extensions.isEmpty()) { + result.append(browserMessageBuilder()->getArrayFromBase64(extensions)); + } + + // The final result should be CBOR encoded + return {m_browserCbor.cborEncodeAttestation(result), credentialPublicKey.pem}; +} + +// Build a short version of the attestation object for webauthn.get +QByteArray BrowserPasskeys::buildGetAttestationObject(const QJsonObject& publicKey) +{ + QByteArray result; + + const auto rpIdHash = browserMessageBuilder()->getSha256Hash(publicKey["rpId"].toString()); + result.append(rpIdHash); + + const auto flags = + setFlagsFromJson(QJsonObject({{"ED", false}, + {"AT", false}, + {"BS", false}, + {"BE", false}, + {"UV", publicKey["userVerification"].toString() != REQUIREMENT_DISCOURAGED}, + {"UP", true}})); + result.append(flags); + + // Signature counter (not supported, always 0 + const char counter[4] = {0x00, 0x00, 0x00, 0x00}; + result.append(QByteArray::fromRawData(counter, 4)); + + return result; +} + +// See: https://w3c.github.io/webauthn/#sctn-encoded-credPubKey-examples +PrivateKey +BrowserPasskeys::buildCredentialPrivateKey(int alg, const QString& predefinedFirst, const QString& predefinedSecond) +{ + // Only support -7, P256 (EC), -8 (EdDSA) and -257 (RSA) for now + if (alg != WebAuthnAlgorithms::ES256 && alg != WebAuthnAlgorithms::RS256 && alg != WebAuthnAlgorithms::EDDSA) { + return {}; + } + + QByteArray firstPart; + QByteArray secondPart; + QByteArray pem; + + if (!predefinedFirst.isEmpty() && !predefinedSecond.isEmpty()) { + firstPart = browserMessageBuilder()->getArrayFromBase64(predefinedFirst); + secondPart = browserMessageBuilder()->getArrayFromBase64(predefinedSecond); + } else { + if (alg == WebAuthnAlgorithms::ES256) { + try { + Botan::ECDSA_PrivateKey privateKey(*randomGen()->getRng(), Botan::EC_Group("secp256r1")); + const auto& publicPoint = privateKey.public_point(); + auto x = publicPoint.get_affine_x(); + auto y = publicPoint.get_affine_y(); + firstPart = bigIntToQByteArray(x); + secondPart = bigIntToQByteArray(y); + + auto privateKeyPem = Botan::PKCS8::PEM_encode(privateKey); + pem = QByteArray::fromStdString(privateKeyPem); + } catch (std::exception& e) { + qWarning("BrowserWebAuthn::buildCredentialPrivateKey: Could not create EC2 private key: %s", e.what()); + return {}; + } + } else if (alg == WebAuthnAlgorithms::RS256) { + try { + Botan::RSA_PrivateKey privateKey(*randomGen()->getRng(), RSA_BITS, RSA_EXPONENT); + auto modulus = privateKey.get_n(); + auto exponent = privateKey.get_e(); + firstPart = bigIntToQByteArray(modulus); + secondPart = bigIntToQByteArray(exponent); + + auto privateKeyPem = Botan::PKCS8::PEM_encode(privateKey); + pem = QByteArray::fromStdString(privateKeyPem); + } catch (std::exception& e) { + qWarning("BrowserWebAuthn::buildCredentialPrivateKey: Could not create RSA private key: %s", e.what()); + return {}; + } + } else if (alg == WebAuthnAlgorithms::EDDSA) { + try { + Botan::Ed25519_PrivateKey key(*randomGen()->getRng()); + auto publicKey = key.get_public_key(); + auto privateKey = key.get_private_key(); + firstPart = browserMessageBuilder()->getQByteArray(publicKey.data(), publicKey.size()); + secondPart = browserMessageBuilder()->getQByteArray(privateKey.data(), privateKey.size()); + + auto privateKeyPem = Botan::PKCS8::PEM_encode(key); + pem = QByteArray::fromStdString(privateKeyPem); + } catch (std::exception& e) { + qWarning("BrowserWebAuthn::buildCredentialPrivateKey: Could not create EdDSA private key: %s", + e.what()); + return {}; + } + } + } + + auto result = m_browserCbor.cborEncodePublicKey(alg, firstPart, secondPart); + return {result, pem}; +} + +QByteArray BrowserPasskeys::buildSignature(const QByteArray& authenticatorData, + const QByteArray& clientData, + const QString& privateKeyPem) +{ + const auto clientDataHash = browserMessageBuilder()->getSha256Hash(clientData); + const auto attToBeSigned = authenticatorData + clientDataHash; + + try { + const auto privateKeyArray = privateKeyPem.toUtf8(); + Botan::DataSource_Memory dataSource(reinterpret_cast(privateKeyArray.constData()), + privateKeyArray.size()); + + const auto key = Botan::PKCS8::load_key(dataSource).release(); + const auto privateKeyBytes = key->private_key_bits(); + const auto algName = key->algo_name(); + const auto algId = key->algorithm_identifier(); + + std::vector rawSignature; + if (algName == "ECDSA") { + Botan::ECDSA_PrivateKey privateKey(algId, privateKeyBytes); +#ifdef WITH_XC_BOTAN3 + Botan::PK_Signer signer( + privateKey, *randomGen()->getRng(), "EMSA1(SHA-256)", Botan::Signature_Format::DerSequence); +#else + Botan::PK_Signer signer(privateKey, *randomGen()->getRng(), "EMSA1(SHA-256)", Botan::DER_SEQUENCE); +#endif + + signer.update(reinterpret_cast(attToBeSigned.constData()), attToBeSigned.size()); + rawSignature = signer.signature(*randomGen()->getRng()); + } else if (algName == "RSA") { + Botan::RSA_PrivateKey privateKey(algId, privateKeyBytes); + Botan::PK_Signer signer(privateKey, *randomGen()->getRng(), "EMSA3(SHA-256)"); + + signer.update(reinterpret_cast(attToBeSigned.constData()), attToBeSigned.size()); + rawSignature = signer.signature(*randomGen()->getRng()); + } else if (algName == "Ed25519") { + Botan::Ed25519_PrivateKey privateKey(algId, privateKeyBytes); + Botan::PK_Signer signer(privateKey, *randomGen()->getRng(), "SHA-512"); + + signer.update(reinterpret_cast(attToBeSigned.constData()), attToBeSigned.size()); + rawSignature = signer.signature(*randomGen()->getRng()); + } else { + qWarning("BrowserWebAuthn::buildSignature: Algorithm not supported"); + return {}; + } + + auto signature = QByteArray(reinterpret_cast(rawSignature.data()), rawSignature.size()); + return signature; + } catch (std::exception& e) { + qWarning("BrowserWebAuthn::buildSignature: Could not sign key: %s", e.what()); + return {}; + } +} + +QByteArray BrowserPasskeys::buildExtensionData(QJsonObject& extensionObject) const +{ + // Only supports "credProps" and "uvm" for now + const QStringList allowedKeys = {"credProps", "uvm"}; + + // Remove unsupported keys + for (const auto& key : extensionObject.keys()) { + if (!allowedKeys.contains(key)) { + extensionObject.remove(key); + } + } + + auto extensionData = m_browserCbor.cborEncodeExtensionData(extensionObject); + if (!extensionData.isEmpty()) { + return extensionData; + } + + return {}; +} + +// Parse authentication data byte array to JSON +// See: https://www.w3.org/TR/webauthn/images/fido-attestation-structures.svg +// And: https://w3c.github.io/webauthn/#attested-credential-data +QJsonObject BrowserPasskeys::parseAuthData(const QByteArray& authData) const +{ + auto rpIdHash = authData.mid(AuthDataOffsets::RPIDHASH, HASH_BYTES); + auto flags = authData.mid(AuthDataOffsets::FLAGS, 1); + auto counter = authData.mid(AuthDataOffsets::SIGNATURE_COUNTER, 4); + auto aaGuid = authData.mid(AuthDataOffsets::AAGUID, 16); + auto credentialLength = authData.mid(AuthDataOffsets::CREDENTIAL_LENGTH, 2); + auto credLen = qFromBigEndian(credentialLength.data()); + auto credentialId = authData.mid(AuthDataOffsets::CREDENTIAL_ID, credLen); + auto publicKey = authData.mid(AuthDataOffsets::CREDENTIAL_ID + credLen); + + QJsonObject credentialDataJson({{"aaguid", browserMessageBuilder()->getBase64FromArray(aaGuid)}, + {"credentialId", browserMessageBuilder()->getBase64FromArray(credentialId)}, + {"publicKey", m_browserCbor.getJsonFromCborData(publicKey)}}); + + QJsonObject result({{"credentialData", credentialDataJson}, + {"flags", parseFlags(flags)}, + {"rpIdHash", browserMessageBuilder()->getBase64FromArray(rpIdHash)}, + {"signatureCounter", QJsonValue(qFromBigEndian(counter))}}); + + return result; +} + +// See: https://w3c.github.io/webauthn/#table-authData +QJsonObject BrowserPasskeys::parseFlags(const QByteArray& flags) const +{ + if (flags.isEmpty()) { + return {}; + } + + auto flagsByte = static_cast(flags[0]); + std::bitset<8> flagBits(flagsByte); + + return QJsonObject({{"ED", flagBits.test(AuthenticatorFlags::ED)}, + {"AT", flagBits.test(AuthenticatorFlags::AT)}, + {"BS", flagBits.test(AuthenticatorFlags::BS)}, + {"BE", flagBits.test(AuthenticatorFlags::BE)}, + {"UV", flagBits.test(AuthenticatorFlags::UV)}, + {"UP", flagBits.test(AuthenticatorFlags::UP)}}); +} + +char BrowserPasskeys::setFlagsFromJson(const QJsonObject& flags) const +{ + if (flags.isEmpty()) { + return 0; + } + + char flagBits = 0x00; + auto setFlag = [&](const char* key, unsigned char bit) { + if (flags[key].toBool()) { + flagBits |= 1 << bit; + } + }; + + setFlag("ED", AuthenticatorFlags::ED); + setFlag("AT", AuthenticatorFlags::AT); + setFlag("BS", AuthenticatorFlags::BS); + setFlag("BE", AuthenticatorFlags::BE); + setFlag("UV", AuthenticatorFlags::UV); + setFlag("UP", AuthenticatorFlags::UP); + + return flagBits; +} + +// Returns the first supported algorithm from the pubKeyCredParams list (only support ES256, RS256 and EdDSA for now) +WebAuthnAlgorithms BrowserPasskeys::getAlgorithmFromPublicKey(const QJsonObject& publicKey) const +{ + const auto pubKeyCredParams = publicKey["pubKeyCredParams"].toArray(); + if (!pubKeyCredParams.isEmpty()) { + const auto alg = pubKeyCredParams.first()["alg"].toInt(); + if (alg == WebAuthnAlgorithms::ES256 || alg == WebAuthnAlgorithms::RS256 || alg == WebAuthnAlgorithms::EDDSA) { + return static_cast(alg); + } + } + + return WebAuthnAlgorithms::ES256; +} + +QByteArray BrowserPasskeys::bigIntToQByteArray(Botan::BigInt& bigInt) const +{ + auto hexString = QString(bigInt.to_hex_string().c_str()); + + // Botan might add a leading "0x" to the hex string depending on the version. Remove it. + if (hexString.startsWith(("0x"))) { + hexString.remove(0, 2); + } + + return browserMessageBuilder()->getArrayFromHexString(hexString); +} diff --git a/src/browser/BrowserPasskeys.h b/src/browser/BrowserPasskeys.h new file mode 100644 index 0000000000..530029b079 --- /dev/null +++ b/src/browser/BrowserPasskeys.h @@ -0,0 +1,143 @@ +/* + * Copyright (C) 2023 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef BROWSERPASSKEYS_H +#define BROWSERPASSKEYS_H + +#include "BrowserCbor.h" +#include +#include + +#include +#include + +#define ID_BYTES 32 +#define HASH_BYTES 32 +#define DEFAULT_TIMEOUT 300000 +#define DEFAULT_DISCOURAGED_TIMEOUT 120000 +#define RSA_BITS 2048 +#define RSA_EXPONENT 65537 + +enum AuthDataOffsets : int +{ + RPIDHASH = 0, + FLAGS = 32, + SIGNATURE_COUNTER = 33, + AAGUID = 37, + CREDENTIAL_LENGTH = 53, + CREDENTIAL_ID = 55 +}; + +enum AuthenticatorFlags +{ + UP = 0, + UV = 2, + BE = 3, + BS = 4, + AT = 6, + ED = 7 +}; + +struct PublicKeyCredential +{ + QString id; + QJsonObject response; + QByteArray key; +}; + +struct PrivateKey +{ + QByteArray cborEncoded; + QByteArray pem; +}; + +// Predefined variables used for testing the class +struct TestingVariables +{ + QString credentialId; + QString first; + QString second; +}; + +class BrowserPasskeys : public QObject +{ + Q_OBJECT + +public: + explicit BrowserPasskeys() = default; + ~BrowserPasskeys() = default; + static BrowserPasskeys* instance(); + + PublicKeyCredential buildRegisterPublicKeyCredential(const QJsonObject& publicKeyCredentialOptions, + const QString& origin, + const TestingVariables& predefinedVariables = {}); + QJsonObject buildGetPublicKeyCredential(const QJsonObject& publicKeyCredentialRequestOptions, + const QString& origin, + const QString& userId, + const QString& userHandle, + const QString& privateKeyPem); + bool isUserVerificationValid(const QString& userVerification) const; + int getTimeout(const QString& userVerification, int timeout) const; + QStringList getAllowedCredentialsFromPublicKey(const QJsonObject& publicKey) const; + + static const QString PUBLIC_KEY; + static const QString REQUIREMENT_DISCOURAGED; + static const QString REQUIREMENT_PREFERRED; + static const QString REQUIREMENT_REQUIRED; + + static const QString PASSKEYS_ATTESTATION_DIRECT; + static const QString PASSKEYS_ATTESTATION_NONE; + + static const QString KPEX_PASSKEY_USERNAME; + static const QString KPEX_PASSKEY_GENERATED_USER_ID; + static const QString KPEX_PASSKEY_PRIVATE_KEY_PEM; + static const QString KPEX_PASSKEY_RELYING_PARTY; + static const QString KPEX_PASSKEY_USER_HANDLE; + +private: + QJsonObject buildClientDataJson(const QJsonObject& publicKey, const QString& origin, bool get); + PrivateKey buildAttestationObject(const QJsonObject& publicKey, + const QString& extensions, + const QString& id, + const TestingVariables& predefinedVariables = {}); + QByteArray buildGetAttestationObject(const QJsonObject& publicKey); + PrivateKey buildCredentialPrivateKey(int alg, + const QString& predefinedFirst = QString(), + const QString& predefinedSecond = QString()); + QByteArray + buildSignature(const QByteArray& authenticatorData, const QByteArray& clientData, const QString& privateKeyPem); + QByteArray buildExtensionData(QJsonObject& extensionObject) const; + QJsonObject parseAuthData(const QByteArray& authData) const; + QJsonObject parseFlags(const QByteArray& flags) const; + char setFlagsFromJson(const QJsonObject& flags) const; + WebAuthnAlgorithms getAlgorithmFromPublicKey(const QJsonObject& publicKey) const; + QByteArray bigIntToQByteArray(Botan::BigInt& bigInt) const; + + Q_DISABLE_COPY(BrowserPasskeys); + + friend class TestPasskeys; + +private: + BrowserCbor m_browserCbor; +}; + +static inline BrowserPasskeys* browserPasskeys() +{ + return BrowserPasskeys::instance(); +} + +#endif // BROWSERPASSKEYS_H diff --git a/src/browser/BrowserPasskeysConfirmationDialog.cpp b/src/browser/BrowserPasskeysConfirmationDialog.cpp new file mode 100644 index 0000000000..af62488f16 --- /dev/null +++ b/src/browser/BrowserPasskeysConfirmationDialog.cpp @@ -0,0 +1,153 @@ +/* + * Copyright (C) 2023 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "BrowserPasskeysConfirmationDialog.h" +#include "ui_BrowserPasskeysConfirmationDialog.h" + +#include "core/Entry.h" +#include +#include + +#define STEP 1000 + +BrowserPasskeysConfirmationDialog::BrowserPasskeysConfirmationDialog(QWidget* parent) + : QDialog(parent) + , m_ui(new Ui::BrowserPasskeysConfirmationDialog()) + , m_passkeyUpdated(false) +{ + setWindowFlags(windowFlags() | Qt::WindowStaysOnTopHint); + + m_ui->setupUi(this); + m_ui->updateButton->setVisible(false); + + connect(m_ui->credentialsTable, SIGNAL(cellDoubleClicked(int, int)), this, SLOT(accept())); + connect(m_ui->confirmButton, SIGNAL(clicked()), SLOT(accept())); + connect(m_ui->cancelButton, SIGNAL(clicked()), SLOT(reject())); + connect(m_ui->updateButton, SIGNAL(clicked()), SLOT(updatePasskey())); + + connect(&m_timer, SIGNAL(timeout()), this, SLOT(updateProgressBar())); + connect(&m_timer, SIGNAL(timeout()), this, SLOT(updateSeconds())); +} + +BrowserPasskeysConfirmationDialog::~BrowserPasskeysConfirmationDialog() +{ +} + +void BrowserPasskeysConfirmationDialog::registerCredential(const QString& username, + const QString& siteId, + const QList& existingEntries, + int timeout) +{ + m_ui->firstLabel->setText(tr("Do you want to register Passkey for:")); + m_ui->dataLabel->setText(tr("%1 (%2)").arg(username, siteId)); + m_ui->secondLabel->setText(""); + + if (!existingEntries.isEmpty()) { + m_ui->firstLabel->setText(tr("Existing Passkey found.\nDo you want to register a new Passkey for:")); + m_ui->secondLabel->setText(tr("Select the existing Passkey and press Update to replace it.")); + + m_ui->updateButton->setVisible(true); + m_ui->confirmButton->setText(tr("Register new")); + updateEntriesToTable(existingEntries); + } else { + m_ui->confirmButton->setText(tr("Register")); + m_ui->credentialsTable->setVisible(false); + } + + startCounter(timeout); +} + +void BrowserPasskeysConfirmationDialog::authenticateCredential(const QList& entries, + const QString& origin, + int timeout) +{ + m_ui->firstLabel->setText(tr("Authenticate Passkey credentials for:")); + m_ui->dataLabel->setText(origin); + m_ui->secondLabel->setText(""); + updateEntriesToTable(entries); + startCounter(timeout); +} + +Entry* BrowserPasskeysConfirmationDialog::getSelectedEntry() const +{ + auto selectedItem = m_ui->credentialsTable->currentItem(); + return selectedItem ? m_entries[selectedItem->row()] : nullptr; +} + +bool BrowserPasskeysConfirmationDialog::isPasskeyUpdated() const +{ + return m_passkeyUpdated; +} + +void BrowserPasskeysConfirmationDialog::updatePasskey() +{ + m_passkeyUpdated = true; + emit accept(); +} + +void BrowserPasskeysConfirmationDialog::updateProgressBar() +{ + if (m_counter < m_ui->progressBar->maximum()) { + m_ui->progressBar->setValue(m_ui->progressBar->maximum() - m_counter); + m_ui->progressBar->update(); + } else { + emit reject(); + } +} + +void BrowserPasskeysConfirmationDialog::updateSeconds() +{ + ++m_counter; + updateTimeoutLabel(); +} + +void BrowserPasskeysConfirmationDialog::startCounter(int timeout) +{ + m_counter = 0; + m_ui->progressBar->setMaximum(timeout / STEP); + updateProgressBar(); + updateTimeoutLabel(); + m_timer.start(STEP); +} + +void BrowserPasskeysConfirmationDialog::updateTimeoutLabel() +{ + m_ui->timeoutLabel->setText(tr("Timeout in %n seconds...", "", m_ui->progressBar->maximum() - m_counter)); +} + +void BrowserPasskeysConfirmationDialog::updateEntriesToTable(const QList& entries) +{ + m_entries = entries; + m_ui->credentialsTable->setRowCount(entries.count()); + m_ui->credentialsTable->setColumnCount(1); + + int row = 0; + for (const auto& entry : entries) { + auto item = new QTableWidgetItem(); + item->setText(entry->title() + " - " + entry->username()); + m_ui->credentialsTable->setItem(row, 0, item); + + if (row == 0) { + item->setSelected(true); + } + + ++row; + } + + m_ui->credentialsTable->resizeColumnsToContents(); + m_ui->credentialsTable->horizontalHeader()->setStretchLastSection(true); +} \ No newline at end of file diff --git a/src/browser/BrowserPasskeysConfirmationDialog.h b/src/browser/BrowserPasskeysConfirmationDialog.h new file mode 100644 index 0000000000..189c066a52 --- /dev/null +++ b/src/browser/BrowserPasskeysConfirmationDialog.h @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2023 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEEPASSXC_BROWSERPASSKEYSCONFIRMATIONDIALOG_H +#define KEEPASSXC_BROWSERPASSKEYSCONFIRMATIONDIALOG_H + +#include +#include +#include + +class Entry; + +namespace Ui +{ + class BrowserPasskeysConfirmationDialog; +} + +class BrowserPasskeysConfirmationDialog : public QDialog +{ + Q_OBJECT + +public: + explicit BrowserPasskeysConfirmationDialog(QWidget* parent = nullptr); + ~BrowserPasskeysConfirmationDialog() override; + + void registerCredential(const QString& username, + const QString& siteId, + const QList& existingEntries, + int timeout); + void authenticateCredential(const QList& entries, const QString& origin, int timeout); + Entry* getSelectedEntry() const; + bool isPasskeyUpdated() const; + +private slots: + void updatePasskey(); + void updateProgressBar(); + void updateSeconds(); + +private: + void startCounter(int timeout); + void updateTimeoutLabel(); + void updateEntriesToTable(const QList& entries); + +private: + QScopedPointer m_ui; + QList m_entries; + QTimer m_timer; + int m_counter; + bool m_passkeyUpdated; +}; + +#endif // KEEPASSXC_BROWSERPASSKEYSCONFIRMATIONDIALOG_H diff --git a/src/browser/BrowserPasskeysConfirmationDialog.ui b/src/browser/BrowserPasskeysConfirmationDialog.ui new file mode 100755 index 0000000000..5b566d725b --- /dev/null +++ b/src/browser/BrowserPasskeysConfirmationDialog.ui @@ -0,0 +1,159 @@ + + + BrowserPasskeysConfirmationDialog + + + + 0 + 0 + 405 + 282 + + + + + 0 + 0 + + + + KeePassXC: Passkey credentials + + + + + + + true + + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + + + + + Qt::PlainText + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + + + + + + true + + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + + + + + Qt::Vertical + + + QSizePolicy::Expanding + + + + 20 + 10 + + + + + + + + + 0 + 0 + + + + QAbstractItemView::NoEditTriggers + + + false + + + QAbstractItemView::SingleSelection + + + false + + + false + + + false + + + + + + + 0 + + + false + + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Cancel + + + true + + + + + + + Update + + + + + + + Authenticate + + + + + + + + + + diff --git a/src/browser/BrowserService.cpp b/src/browser/BrowserService.cpp index cff27209a7..0a2ec6eaf3 100644 --- a/src/browser/BrowserService.cpp +++ b/src/browser/BrowserService.cpp @@ -29,6 +29,10 @@ #include "gui/MainWindow.h" #include "gui/MessageBox.h" #include "gui/osutils/OSUtils.h" +#ifdef WITH_XC_BROWSER_PASSKEYS +#include "BrowserPasskeys.h" +#include "BrowserPasskeysConfirmationDialog.h" +#endif #ifdef Q_OS_MACOS #include "gui/osutils/macutils/MacUtils.h" #endif @@ -48,6 +52,9 @@ const QString BrowserService::KEEPASSXCBROWSER_NAME = QStringLiteral("KeePassXC- const QString BrowserService::KEEPASSXCBROWSER_OLD_NAME = QStringLiteral("keepassxc-browser Settings"); static const QString KEEPASSXCBROWSER_GROUP_NAME = QStringLiteral("KeePassXC-Browser Passwords"); static int KEEPASSXCBROWSER_DEFAULT_ICON = 1; +#ifdef WITH_XC_BROWSER_PASSKEYS +static int KEEPASSXCBROWSER_PASSKEY_ICON = 13; +#endif // These are for the settings and password conversion static const QString KEEPASSHTTP_NAME = QStringLiteral("KeePassHttp Settings"); static const QString KEEPASSHTTP_GROUP_NAME = QStringLiteral("KeePassHttp Passwords"); @@ -607,6 +614,177 @@ QString BrowserService::getKey(const QString& id) return db->metadata()->customData()->value(CustomData::BrowserKeyPrefix + id); } +#ifdef WITH_XC_BROWSER_PASSKEYS +// Passkey registration +QJsonObject BrowserService::showPasskeysRegisterPrompt(const QJsonObject& publicKey, + const QString& origin, + const StringPairList& keyList) +{ + auto db = selectedDatabase(); + if (!db) { + return getPasskeyError(ERROR_KEEPASS_DATABASE_NOT_OPENED); + } + + const auto userJson = publicKey["user"].toObject(); + const auto username = userJson["name"].toString(); + const auto userHandle = userJson["id"].toString(); + const auto rpId = publicKey["rp"]["id"].toString(); + const auto rpName = publicKey["rp"]["name"].toString(); + const auto timeoutValue = publicKey["timeout"].toInt(); + const auto excludeCredentials = publicKey["excludeCredentials"].toArray(); + const auto attestation = publicKey["attestation"].toString(); + + // Only support these two for now + if (attestation != BrowserPasskeys::PASSKEYS_ATTESTATION_NONE + && attestation != BrowserPasskeys::PASSKEYS_ATTESTATION_DIRECT) { + return getPasskeyError(ERROR_PASSKEYS_ATTESTATION_NOT_SUPPORTED); + } + + const auto authenticatorSelection = publicKey["authenticatorSelection"].toObject(); + const auto userVerification = authenticatorSelection["userVerification"].toString(); + if (!browserPasskeys()->isUserVerificationValid(userVerification)) { + return getPasskeyError(ERROR_PASSKEYS_INVALID_USER_VERIFICATION); + } + + if (!excludeCredentials.isEmpty() && isPasskeyCredentialExcluded(excludeCredentials, origin, keyList)) { + return getPasskeyError(ERROR_PASSKEYS_CREDENTIAL_IS_EXCLUDED); + } + + const auto existingEntries = getPasskeyEntries(rpId, keyList); + const auto timeout = browserPasskeys()->getTimeout(userVerification, timeoutValue); + + raiseWindow(); + BrowserPasskeysConfirmationDialog confirmDialog; + confirmDialog.registerCredential(username, rpId, existingEntries, timeout); + + auto dialogResult = confirmDialog.exec(); + if (dialogResult == QDialog::Accepted) { + const auto publicKeyCredentials = browserPasskeys()->buildRegisterPublicKeyCredential(publicKey, origin); + + if (confirmDialog.isPasskeyUpdated()) { + addPasskeyToEntry(confirmDialog.getSelectedEntry(), + rpId, + rpName, + username, + publicKeyCredentials.id, + userHandle, + publicKeyCredentials.key); + } else { + addPasskeyToGroup( + nullptr, origin, rpId, rpName, username, publicKeyCredentials.id, userHandle, publicKeyCredentials.key); + } + + hideWindow(); + return publicKeyCredentials.response; + } + + hideWindow(); + return getPasskeyError(ERROR_PASSKEYS_REQUEST_CANCELED); +} + +// Passkey authentication +QJsonObject BrowserService::showPasskeysAuthenticationPrompt(const QJsonObject& publicKey, + const QString& origin, + const StringPairList& keyList) +{ + auto db = selectedDatabase(); + if (!db) { + return getPasskeyError(ERROR_KEEPASS_DATABASE_NOT_OPENED); + } + + const auto userVerification = publicKey["userVerification"].toString(); + if (!browserPasskeys()->isUserVerificationValid(userVerification)) { + return getPasskeyError(ERROR_PASSKEYS_INVALID_USER_VERIFICATION); + } + + // Parse "allowCredentials" + const auto rpId = publicKey["rpId"].toString(); + const auto entries = getPasskeyAllowedEntries(publicKey, rpId, keyList); + if (entries.isEmpty()) { + return getPasskeyError(ERROR_KEEPASS_NO_LOGINS_FOUND); + } + + // With single entry, if no verification is needed, return directly + if (entries.count() == 1 && userVerification == BrowserPasskeys::REQUIREMENT_DISCOURAGED) { + return getPublicKeyCredentialFromEntry(entries.first(), publicKey, origin); + } + + const auto timeout = publicKey["timeout"].toInt(); + + raiseWindow(); + BrowserPasskeysConfirmationDialog confirmDialog; + confirmDialog.authenticateCredential(entries, origin, timeout); + auto dialogResult = confirmDialog.exec(); + if (dialogResult == QDialog::Accepted) { + hideWindow(); + const auto selectedEntry = confirmDialog.getSelectedEntry(); + return getPublicKeyCredentialFromEntry(selectedEntry, publicKey, origin); + } + + hideWindow(); + return getPasskeyError(ERROR_PASSKEYS_REQUEST_CANCELED); +} + +void BrowserService::addPasskeyToGroup(Group* group, + const QString& url, + const QString& rpId, + const QString& rpName, + const QString& username, + const QString& userId, + const QString& userHandle, + const QString& privateKey) +{ + // If no group provided, use the default browser group of the selected database + if (!group) { + auto db = selectedDatabase(); + if (!db) { + return; + } + group = getDefaultEntryGroup(db); + } + + auto* entry = new Entry(); + entry->setUuid(QUuid::createUuid()); + entry->setGroup(group); + entry->setTitle(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 -BrowserService::searchEntries(const QSharedPointer& db, const QString& siteUrl, const QString& formUrl) +QList BrowserService::searchEntries(const QSharedPointer& db, + const QString& siteUrl, + const QString& formUrl, + bool passkey) { QList entries; auto* rootGroup = db->rootGroup(); @@ -771,9 +951,16 @@ BrowserService::searchEntries(const QSharedPointer& db, const QString& continue; } - if (!shouldIncludeEntry(entry, siteUrl, formUrl, omitWwwSubdomain)) { + if (!passkey && !shouldIncludeEntry(entry, siteUrl, formUrl, omitWwwSubdomain)) { + continue; + } + +#ifdef WITH_XC_BROWSER_PASSKEYS + // With Passkeys, check for the Relying Party instead of URL + if (passkey && entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_RELYING_PARTY) != siteUrl) { continue; } +#endif // Additional URL check may have already inserted the entry to the list if (!entries.contains(entry)) { @@ -785,8 +972,10 @@ BrowserService::searchEntries(const QSharedPointer& db, const QString& return entries; } -QList -BrowserService::searchEntries(const QString& siteUrl, const QString& formUrl, const StringPairList& keyList) +QList BrowserService::searchEntries(const QString& siteUrl, + const QString& formUrl, + const StringPairList& keyList, + bool passkey) { // Check if database is connected with KeePassXC-Browser auto databaseConnected = [&](const QSharedPointer& db) { @@ -820,7 +1009,7 @@ BrowserService::searchEntries(const QString& siteUrl, const QString& formUrl, co QList entries; do { for (const auto& db : databases) { - entries << searchEntries(db, siteUrl, formUrl); + entries << searchEntries(db, siteUrl, formUrl, passkey); } } while (entries.isEmpty() && removeFirstDomain(hostname)); @@ -1094,6 +1283,74 @@ bool BrowserService::shouldIncludeEntry(Entry* entry, return false; } +#ifdef WITH_XC_BROWSER_PASSKEYS +// Returns all Passkey entries for the current Relying Party +QList BrowserService::getPasskeyEntries(const QString& rpId, const StringPairList& keyList) +{ + QList entries; + for (const auto& entry : searchEntries(rpId, "", keyList, true)) { + if (entry->attributes()->hasKey(BrowserPasskeys::KPEX_PASSKEY_PRIVATE_KEY_PEM) + && entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_RELYING_PARTY) == rpId) { + entries << entry; + } + } + + return entries; +} + +// Get all entries for the site that are allowed by the server +QList BrowserService::getPasskeyAllowedEntries(const QJsonObject& publicKey, + const QString& rpId, + const StringPairList& keyList) +{ + QList entries; + const auto allowedCredentials = browserPasskeys()->getAllowedCredentialsFromPublicKey(publicKey); + + for (const auto& entry : getPasskeyEntries(rpId, keyList)) { + // If allowedCredentials.isEmpty() check if entry contains an extra attribute for user handle. + // If that is found, the entry should be allowed. + // See: https://w3c.github.io/webauthn/#dom-authenticatorassertionresponse-userhandle + if (allowedCredentials.contains(entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_GENERATED_USER_ID)) + || (allowedCredentials.isEmpty() + && entry->attributes()->hasKey(BrowserPasskeys::KPEX_PASSKEY_USER_HANDLE))) { + entries << entry; + } + } + + return entries; +} + +QJsonObject +BrowserService::getPublicKeyCredentialFromEntry(const Entry* entry, const QJsonObject& publicKey, const QString& origin) +{ + const auto privateKeyPem = entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_PRIVATE_KEY_PEM); + const auto userId = entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_GENERATED_USER_ID); + const auto userHandle = entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_USER_HANDLE); + return browserPasskeys()->buildGetPublicKeyCredential(publicKey, origin, userId, userHandle, privateKeyPem); +} + +// Checks if the same user ID already exists for the current site +bool BrowserService::isPasskeyCredentialExcluded(const QJsonArray& excludeCredentials, + const QString& origin, + const StringPairList& keyList) +{ + QStringList allIds; + for (const auto& cred : excludeCredentials) { + allIds << cred["id"].toString(); + } + + const auto passkeyEntries = getPasskeyEntries(origin, keyList); + return std::any_of(passkeyEntries.begin(), passkeyEntries.end(), [&](const auto& entry) { + return allIds.contains(entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_GENERATED_USER_ID)); + }); +} + +QJsonObject BrowserService::getPasskeyError(int errorCode) const +{ + return QJsonObject({{"errorCode", errorCode}}); +} +#endif + bool BrowserService::handleURL(const QString& entryUrl, const QString& siteUrl, const QString& formUrl, diff --git a/src/browser/BrowserService.h b/src/browser/BrowserService.h index ca3579e024..01daaee852 100644 --- a/src/browser/BrowserService.h +++ b/src/browser/BrowserService.h @@ -17,10 +17,11 @@ * along with this program. If not, see . */ -#ifndef BROWSERSERVICE_H -#define BROWSERSERVICE_H +#ifndef KEEPASSXC_BROWSERSERVICE_H +#define KEEPASSXC_BROWSERSERVICE_H #include "BrowserAccessControlDialog.h" +#include "config-keepassx.h" #include "core/Entry.h" #include "gui/PasswordGeneratorWidget.h" @@ -45,6 +46,7 @@ struct KeyPairMessage struct EntryParameters { QString dbid; + QString title; QString login; QString password; QString realm; @@ -82,7 +84,30 @@ class BrowserService : public QObject QString getCurrentTotp(const QString& uuid); void showPasswordGenerator(const KeyPairMessage& keyPairMessage); bool isPasswordGeneratorRequested() const; - + bool isUrlIdentical(const QString& first, const QString& second) const; + QSharedPointer selectedDatabase(); +#ifdef WITH_XC_BROWSER_PASSKEYS + QJsonObject + showPasskeysRegisterPrompt(const QJsonObject& publicKey, const QString& origin, const StringPairList& keyList); + QJsonObject showPasskeysAuthenticationPrompt(const QJsonObject& publicKey, + const QString& origin, + const StringPairList& keyList); + void addPasskeyToGroup(Group* group, + const QString& url, + const QString& rpId, + const QString& rpName, + const QString& username, + const QString& userId, + const QString& userHandle, + const QString& privateKey); + void addPasskeyToEntry(Entry* entry, + const QString& rpId, + const QString& rpName, + const QString& username, + const QString& userId, + const QString& userHandle, + const QString& privateKey); +#endif void addEntry(const EntryParameters& entryParameters, const QString& group, const QString& groupUuid, @@ -129,8 +154,12 @@ private slots: Hidden }; - QList searchEntries(const QSharedPointer& db, const QString& siteUrl, const QString& formUrl); - QList searchEntries(const QString& siteUrl, const QString& formUrl, const StringPairList& keyList); + QList searchEntries(const QSharedPointer& db, + const QString& siteUrl, + const QString& formUrl, + bool passkey = false); + QList + searchEntries(const QString& siteUrl, const QString& formUrl, const StringPairList& keyList, bool passkey = false); QList sortEntries(QList& entries, const QString& siteUrl, const QString& formUrl); QList confirmEntries(QList& entriesToConfirm, const EntryParameters& entryParameters, @@ -148,12 +177,22 @@ private slots: bool removeFirstDomain(QString& hostname); bool shouldIncludeEntry(Entry* entry, const QString& url, const QString& submitUrl, const bool omitWwwSubdomain = false); +#ifdef WITH_XC_BROWSER_PASSKEYS + QList getPasskeyEntries(const QString& rpId, const StringPairList& keyList); + QList + getPasskeyAllowedEntries(const QJsonObject& publicKey, const QString& rpId, const StringPairList& keyList); + QJsonObject + getPublicKeyCredentialFromEntry(const Entry* entry, const QJsonObject& publicKey, const QString& origin); + bool isPasskeyCredentialExcluded(const QJsonArray& excludeCredentials, + const QString& origin, + const StringPairList& keyList); + QJsonObject getPasskeyError(int errorCode) const; +#endif bool handleURL(const QString& entryUrl, const QString& siteUrl, const QString& formUrl, const bool omitWwwSubdomain = false); QSharedPointer getDatabase(); - QSharedPointer selectedDatabase(); QString getDatabaseRootUuid(); QString getDatabaseRecycleBinUuid(); bool checkLegacySettings(QSharedPointer db); @@ -176,6 +215,9 @@ private slots: Q_DISABLE_COPY(BrowserService); friend class TestBrowser; +#ifdef WITH_XC_BROWSER_PASSKEYS + friend class TestPasskeys; +#endif }; static inline BrowserService* browserService() @@ -183,4 +225,4 @@ static inline BrowserService* browserService() return BrowserService::instance(); } -#endif // BROWSERSERVICE_H +#endif // KEEPASSXC_BROWSERSERVICE_H diff --git a/src/browser/BrowserSettings.h b/src/browser/BrowserSettings.h index b61c8d40cf..cecf1cba7c 100644 --- a/src/browser/BrowserSettings.h +++ b/src/browser/BrowserSettings.h @@ -17,8 +17,8 @@ * along with this program. If not, see . */ -#ifndef BROWSERSETTINGS_H -#define BROWSERSETTINGS_H +#ifndef KEEPASSXC_BROWSERSETTINGS_H +#define KEEPASSXC_BROWSERSETTINGS_H #include "NativeMessageInstaller.h" @@ -92,4 +92,4 @@ inline BrowserSettings* browserSettings() return BrowserSettings::instance(); } -#endif // BROWSERSETTINGS_H +#endif // KEEPASSXC_BROWSERSETTINGS_H diff --git a/src/browser/CMakeLists.txt b/src/browser/CMakeLists.txt index ef70080be1..656b5a5288 100755 --- a/src/browser/CMakeLists.txt +++ b/src/browser/CMakeLists.txt @@ -1,5 +1,4 @@ # Copyright (C) 2023 KeePassXC Team -# Copyright (C) 2017 Sami Vänttinen # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -30,8 +29,14 @@ if(WITH_XC_BROWSER) BrowserSettings.cpp BrowserShared.cpp CustomTableWidget.cpp - NativeMessageInstaller.cpp - ) + NativeMessageInstaller.cpp) + + if(WITH_XC_BROWSER_PASSKEYS) + list(APPEND keepassxcbrowser_SOURCES + BrowserCbor.cpp + BrowserPasskeys.cpp + BrowserPasskeysConfirmationDialog.cpp) + endif() add_library(keepassxcbrowser STATIC ${keepassxcbrowser_SOURCES}) target_link_libraries(keepassxcbrowser Qt5::Core Qt5::Concurrent Qt5::Widgets Qt5::Network ${BOTAN_LIBRARIES}) diff --git a/src/config-keepassx.h.cmake b/src/config-keepassx.h.cmake index 840ba0d5e2..1b45315f68 100644 --- a/src/config-keepassx.h.cmake +++ b/src/config-keepassx.h.cmake @@ -15,6 +15,7 @@ #cmakedefine WITH_XC_AUTOTYPE #cmakedefine WITH_XC_NETWORKING #cmakedefine WITH_XC_BROWSER +#cmakedefine WITH_XC_BROWSER_PASSKEYS #cmakedefine WITH_XC_YUBIKEY #cmakedefine WITH_XC_SSHAGENT #cmakedefine WITH_XC_KEESHARE diff --git a/src/core/EntryAttributes.cpp b/src/core/EntryAttributes.cpp index 6dfc8adbaa..13207e1688 100644 --- a/src/core/EntryAttributes.cpp +++ b/src/core/EntryAttributes.cpp @@ -1,6 +1,6 @@ /* + * Copyright (C) 2023 KeePassXC Team * Copyright (C) 2012 Felix Geyer - * Copyright (C) 2017 KeePassXC Team * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -17,7 +17,6 @@ */ #include "EntryAttributes.h" - #include "core/Global.h" #include @@ -35,6 +34,7 @@ const QString EntryAttributes::SearchInGroupName = "SearchIn"; const QString EntryAttributes::SearchTextGroupName = "SearchText"; const QString EntryAttributes::RememberCmdExecAttr = "_EXEC_CMD"; +const QString EntryAttributes::PasskeyAttribute = "KPEX_PASSKEY"; EntryAttributes::EntryAttributes(QObject* parent) : ModifiableObject(parent) @@ -57,7 +57,7 @@ QList EntryAttributes::customKeys() const QList customKeys; const QList keyList = keys(); for (const QString& key : keyList) { - if (!isDefaultAttribute(key)) { + if (!isDefaultAttribute(key) && !isPasskeyAttribute(key)) { customKeys.append(key); } } @@ -321,3 +321,8 @@ bool EntryAttributes::isDefaultAttribute(const QString& key) { return DefaultAttributes.contains(key); } + +bool EntryAttributes::isPasskeyAttribute(const QString& key) +{ + return key.startsWith(PasskeyAttribute); +} diff --git a/src/core/EntryAttributes.h b/src/core/EntryAttributes.h index a9fcf7f60c..2e7f8c05c0 100644 --- a/src/core/EntryAttributes.h +++ b/src/core/EntryAttributes.h @@ -61,7 +61,9 @@ class EntryAttributes : public ModifiableObject static const QString NotesKey; static const QStringList DefaultAttributes; static const QString RememberCmdExecAttr; + static const QString PasskeyAttribute; static bool isDefaultAttribute(const QString& key); + static bool isPasskeyAttribute(const QString& key); static const QString WantedFieldGroupName; static const QString SearchInGroupName; diff --git a/src/core/Group.cpp b/src/core/Group.cpp index 9240d0c098..6498331788 100644 --- a/src/core/Group.cpp +++ b/src/core/Group.cpp @@ -131,6 +131,19 @@ QString Group::tags() const return m_data.tags; } +QString Group::fullPath() const +{ + QString fullPath; + auto group = this; + + do { + fullPath.insert(0, "/" + group->name()); + group = group->parentGroup(); + } while (group); + + return fullPath; +} + int Group::iconNumber() const { return m_data.iconNumber; diff --git a/src/core/Group.h b/src/core/Group.h index 8e04a6062b..ac28f73e0e 100644 --- a/src/core/Group.h +++ b/src/core/Group.h @@ -85,6 +85,7 @@ class Group : public ModifiableObject QString name() const; QString notes() const; QString tags() const; + QString fullPath() const; int iconNumber() const; const QUuid& iconUuid() const; const TimeInfo& timeInfo() const; diff --git a/src/core/Tools.cpp b/src/core/Tools.cpp index 824f9ff924..cefb0448d9 100644 --- a/src/core/Tools.cpp +++ b/src/core/Tools.cpp @@ -94,6 +94,9 @@ namespace Tools #ifdef WITH_XC_BROWSER extensions += "\n- " + QObject::tr("Browser Integration"); #endif +#ifdef WITH_XC_BROWSER_PASSKEYS + extensions += "\n- " + QObject::tr("Passkeys"); +#endif #ifdef WITH_XC_SSHAGENT extensions += "\n- " + QObject::tr("SSH Agent"); #endif @@ -408,6 +411,16 @@ namespace Tools return subbed; } + QString cleanFilename(QString filename) + { + // Remove forward slash from title on all platforms + filename.replace("/", "_"); + // Remove invalid characters + filename.remove(QRegularExpression("[:*?\"<>|]")); + + return filename.trimmed(); + } + QVariantMap qo2qvm(const QObject* object, const QStringList& ignoredProperties) { QVariantMap result; diff --git a/src/core/Tools.h b/src/core/Tools.h index 3df2ca0085..4316a44e84 100644 --- a/src/core/Tools.h +++ b/src/core/Tools.h @@ -43,6 +43,7 @@ namespace Tools bool isValidUuid(const QString& uuidStr); QString envSubstitute(const QString& filepath, QProcessEnvironment environment = QProcessEnvironment::systemEnvironment()); + QString cleanFilename(QString filename); /** * Escapes all characters in regex such that they do not receive any special treatment when used diff --git a/src/gui/DatabaseTabWidget.cpp b/src/gui/DatabaseTabWidget.cpp index 4e422ebb2f..4454266339 100644 --- a/src/gui/DatabaseTabWidget.cpp +++ b/src/gui/DatabaseTabWidget.cpp @@ -556,6 +556,18 @@ void DatabaseTabWidget::showDatabaseSettings() currentDatabaseWidget()->switchToDatabaseSettings(); } +#ifdef WITH_XC_BROWSER_PASSKEYS +void DatabaseTabWidget::showPasskeys() +{ + currentDatabaseWidget()->switchToPasskeys(); +} + +void DatabaseTabWidget::importPasskey() +{ + currentDatabaseWidget()->switchToImportPasskey(); +} +#endif + bool DatabaseTabWidget::isModified(int index) const { if (count() == 0) { diff --git a/src/gui/DatabaseTabWidget.h b/src/gui/DatabaseTabWidget.h index 58f5408e3a..6b4b121af7 100644 --- a/src/gui/DatabaseTabWidget.h +++ b/src/gui/DatabaseTabWidget.h @@ -19,6 +19,7 @@ #define KEEPASSX_DATABASETABWIDGET_H #include "DatabaseOpenDialog.h" +#include "config-keepassx.h" #include "gui/MessageWidget.h" #include @@ -84,6 +85,10 @@ public slots: void showDatabaseSecurity(); void showDatabaseReports(); void showDatabaseSettings(); +#ifdef WITH_XC_BROWSER_PASSKEYS + void showPasskeys(); + void importPasskey(); +#endif void performGlobalAutoType(const QString& search); void performBrowserUnlock(); diff --git a/src/gui/DatabaseWidget.cpp b/src/gui/DatabaseWidget.cpp index 530c5e799a..d42292d348 100644 --- a/src/gui/DatabaseWidget.cpp +++ b/src/gui/DatabaseWidget.cpp @@ -64,6 +64,10 @@ #include "sshagent/SSHAgent.h" #endif +#ifdef WITH_XC_BROWSER_PASSKEYS +#include "gui/passkeys/PasskeyImporter.h" +#endif + DatabaseWidget::DatabaseWidget(QSharedPointer db, QWidget* parent) : QStackedWidget(parent) , m_db(std::move(db)) @@ -1396,6 +1400,20 @@ void DatabaseWidget::switchToDatabaseSecurity() m_databaseSettingDialog->showDatabaseKeySettings(); } +#ifdef WITH_XC_BROWSER_PASSKEYS +void DatabaseWidget::switchToPasskeys() +{ + switchToDatabaseReports(); + m_reportsDialog->activatePasskeysPage(); +} + +void DatabaseWidget::switchToImportPasskey() +{ + PasskeyImporter passkeyImporter; + passkeyImporter.importPasskey(m_db); +} +#endif + void DatabaseWidget::performUnlockDatabase(const QString& password, const QString& keyfile) { if (password.isEmpty() && keyfile.isEmpty()) { diff --git a/src/gui/DatabaseWidget.h b/src/gui/DatabaseWidget.h index 676bf63a2d..4f79ebd2f8 100644 --- a/src/gui/DatabaseWidget.h +++ b/src/gui/DatabaseWidget.h @@ -212,6 +212,10 @@ public slots: void switchToDatabaseSecurity(); void switchToDatabaseReports(); void switchToDatabaseSettings(); +#ifdef WITH_XC_BROWSER_PASSKEYS + void switchToPasskeys(); + void switchToImportPasskey(); +#endif void switchToOpenDatabase(); void switchToOpenDatabase(const QString& filePath); void switchToOpenDatabase(const QString& filePath, const QString& password, const QString& keyFile); diff --git a/src/gui/MainWindow.cpp b/src/gui/MainWindow.cpp index c03bd43005..27dada98d6 100644 --- a/src/gui/MainWindow.cpp +++ b/src/gui/MainWindow.cpp @@ -438,6 +438,11 @@ MainWindow::MainWindow() m_ui->actionKeyboardShortcuts->setIcon(icons()->icon("keyboard-shortcuts")); m_ui->actionCheckForUpdates->setIcon(icons()->icon("system-software-update")); +#ifdef WITH_XC_BROWSER_PASSKEYS + m_ui->actionPasskeys->setIcon(icons()->icon("passkey")); + m_ui->actionImportPasskey->setIcon(icons()->icon("document-import")); +#endif + m_actionMultiplexer.connect( SIGNAL(currentModeChanged(DatabaseWidget::Mode)), this, SLOT(setMenuActionState(DatabaseWidget::Mode))); m_actionMultiplexer.connect(SIGNAL(groupChanged()), this, SLOT(setMenuActionState())); @@ -483,6 +488,10 @@ MainWindow::MainWindow() connect(m_ui->actionDatabaseSecurity, SIGNAL(triggered()), m_ui->tabWidget, SLOT(showDatabaseSecurity())); connect(m_ui->actionReports, SIGNAL(triggered()), m_ui->tabWidget, SLOT(showDatabaseReports())); connect(m_ui->actionDatabaseSettings, SIGNAL(triggered()), m_ui->tabWidget, SLOT(showDatabaseSettings())); +#ifdef WITH_XC_BROWSER_PASSKEYS + connect(m_ui->actionPasskeys, SIGNAL(triggered()), m_ui->tabWidget, SLOT(showPasskeys())); + connect(m_ui->actionImportPasskey, SIGNAL(triggered()), m_ui->tabWidget, SLOT(importPasskey())); +#endif connect(m_ui->actionImportCsv, SIGNAL(triggered()), m_ui->tabWidget, SLOT(importCsv())); connect(m_ui->actionImportKeePass1, SIGNAL(triggered()), m_ui->tabWidget, SLOT(importKeePass1Database())); connect(m_ui->actionImportOpVault, SIGNAL(triggered()), m_ui->tabWidget, SLOT(importOpVaultDatabase())); @@ -977,6 +986,10 @@ void MainWindow::setMenuActionState(DatabaseWidget::Mode mode) m_ui->actionExportHtml->setEnabled(true); m_ui->actionExportXML->setEnabled(true); m_ui->actionDatabaseMerge->setEnabled(m_ui->tabWidget->currentIndex() != -1); +#ifdef WITH_XC_BROWSER_PASSKEYS + m_ui->actionPasskeys->setEnabled(true); + m_ui->actionImportPasskey->setEnabled(true); +#endif #ifdef WITH_XC_SSHAGENT bool singleEntryHasSshKey = singleEntrySelected && sshAgent()->isEnabled() && dbWidget->currentEntryHasSshKey(); @@ -1044,6 +1057,14 @@ void MainWindow::setMenuActionState(DatabaseWidget::Mode mode) m_ui->actionEntryRemoveFromAgent->setVisible(false); m_ui->actionGroupEmptyRecycleBin->setVisible(false); +#ifdef WITH_XC_BROWSER_PASSKEYS + m_ui->actionPasskeys->setEnabled(false); + m_ui->actionImportPasskey->setEnabled(false); +#else + m_ui->actionPasskeys->setVisible(false); + m_ui->actionImportPasskey->setVisible(false); +#endif + m_searchWidgetAction->setEnabled(false); break; } diff --git a/src/gui/MainWindow.ui b/src/gui/MainWindow.ui index cbaea45a3c..e6fbfc22ca 100644 --- a/src/gui/MainWindow.ui +++ b/src/gui/MainWindow.ui @@ -262,6 +262,9 @@ + + + @@ -620,6 +623,34 @@ QAction::NoRole + + + false + + + Passkeys… + + + Passkeys + + + QAction::NoRole + + + + + false + + + Import Passkey + + + Import Passkey + + + QAction::NoRole + + false diff --git a/src/gui/passkeys/PasskeyExportDialog.cpp b/src/gui/passkeys/PasskeyExportDialog.cpp new file mode 100644 index 0000000000..84e984df60 --- /dev/null +++ b/src/gui/passkeys/PasskeyExportDialog.cpp @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2023 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "PasskeyExportDialog.h" +#include "ui_PasskeyExportDialog.h" + +#include "core/Entry.h" +#include "gui/FileDialog.h" + +PasskeyExportDialog::PasskeyExportDialog(QWidget* parent) + : QDialog(parent) + , m_ui(new Ui::PasskeyExportDialog()) +{ + setWindowFlags(windowFlags() | Qt::WindowStaysOnTopHint); + + m_ui->setupUi(this); + + connect(m_ui->exportButton, SIGNAL(clicked()), SLOT(accept())); + connect(m_ui->cancelButton, SIGNAL(clicked()), SLOT(reject())); + connect(m_ui->itemsTable->selectionModel(), + SIGNAL(selectionChanged(QItemSelection, QItemSelection)), + this, + SLOT(selectionChanged())); +} + +PasskeyExportDialog::~PasskeyExportDialog() +{ +} + +void PasskeyExportDialog::setEntries(const QList& items) +{ + m_ui->itemsTable->setRowCount(items.count()); + m_ui->itemsTable->setColumnCount(1); + + int row = 0; + for (const auto& entry : items) { + auto item = new QTableWidgetItem(); + item->setText(entry->title() + " - " + entry->username()); + item->setData(Qt::UserRole, row); + item->setFlags(item->flags() | Qt::ItemIsSelectable); + m_ui->itemsTable->setItem(row, 0, item); + + ++row; + } + m_ui->itemsTable->resizeColumnsToContents(); + m_ui->itemsTable->horizontalHeader()->setSectionResizeMode(0, QHeaderView::Stretch); + m_ui->itemsTable->selectAll(); + m_ui->exportButton->setFocus(); +} + +QList PasskeyExportDialog::getSelectedItems() const +{ + QList selected; + for (int i = 0; i < m_ui->itemsTable->rowCount(); ++i) { + auto item = m_ui->itemsTable->item(i, 0); + if (item->isSelected()) { + selected.append(item); + } + } + return selected; +} + +void PasskeyExportDialog::selectionChanged() +{ + auto indexes = m_ui->itemsTable->selectionModel()->selectedIndexes(); + m_ui->exportButton->setEnabled(!indexes.isEmpty()); + + if (indexes.isEmpty()) { + m_ui->exportButton->clearFocus(); + m_ui->cancelButton->setFocus(); + } +} + +QString PasskeyExportDialog::selectExportFolder() +{ + return fileDialog()->getExistingDirectory(this, tr("Export to folder"), FileDialog::getLastDir("passkey")); +} diff --git a/src/gui/passkeys/PasskeyExportDialog.h b/src/gui/passkeys/PasskeyExportDialog.h new file mode 100644 index 0000000000..7104583ad8 --- /dev/null +++ b/src/gui/passkeys/PasskeyExportDialog.h @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2023 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEEPASSXC_PASSKEYEXPORTDIALOG_H +#define KEEPASSXC_PASSKEYEXPORTDIALOG_H + +#include +#include + +class Entry; + +namespace Ui +{ + class PasskeyExportDialog; +} + +class PasskeyExportDialog : public QDialog +{ + Q_OBJECT + +public: + explicit PasskeyExportDialog(QWidget* parent = nullptr); + ~PasskeyExportDialog() override; + + void setEntries(const QList& items); + QList getSelectedItems() const; + QString selectExportFolder(); + +private slots: + void selectionChanged(); + +private: + QScopedPointer m_ui; + QList m_entriesToConfirm; + QList m_allowedEntries; +}; + +#endif // KEEPASSXC_PASSKEYEXPORTDIALOG_H diff --git a/src/gui/passkeys/PasskeyExportDialog.ui b/src/gui/passkeys/PasskeyExportDialog.ui new file mode 100755 index 0000000000..4d4d7f5dbc --- /dev/null +++ b/src/gui/passkeys/PasskeyExportDialog.ui @@ -0,0 +1,121 @@ + + + PasskeyExportDialog + + + + 0 + 0 + 540 + 320 + + + + KeePassXC - Passkey Export + + + + + + + 75 + true + + + + Export the following Passkey entries. + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter + + + + + + + + 75 + true + + + + Filenames will be generated with title and .passkey file extension. + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter + + + + + + + QAbstractItemView::NoEditTriggers + + + false + + + QAbstractItemView::ExtendedSelection + + + QAbstractItemView::SelectRows + + + false + + + false + + + false + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Export entries + + + Export Selected + + + true + + + true + + + + + + + Cancel + + + true + + + + + + + + + + diff --git a/src/gui/passkeys/PasskeyExporter.cpp b/src/gui/passkeys/PasskeyExporter.cpp new file mode 100644 index 0000000000..36fa2e4499 --- /dev/null +++ b/src/gui/passkeys/PasskeyExporter.cpp @@ -0,0 +1,105 @@ +/* + * Copyright (C) 2023 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "PasskeyExporter.h" +#include "PasskeyExportDialog.h" + +#include "browser/BrowserPasskeys.h" +#include "core/Entry.h" +#include "core/Tools.h" +#include "gui/MessageBox.h" +#include +#include +#include + +void PasskeyExporter::showExportDialog(const QList& items) +{ + if (items.isEmpty()) { + return; + } + + PasskeyExportDialog passkeyExportDialog; + passkeyExportDialog.setEntries(items); + auto ret = passkeyExportDialog.exec(); + + if (ret == QDialog::Accepted) { + // Select folder + auto folder = passkeyExportDialog.selectExportFolder(); + if (folder.isEmpty()) { + return; + } + + const auto selectedItems = passkeyExportDialog.getSelectedItems(); + for (const auto& item : selectedItems) { + auto entry = items[item->row()]; + exportSelectedEntry(entry, folder); + } + } +} + +/** + * Creates an export file for a Passkey credential + * + * File contents in JSON: + * { + * "privateKey": , + * "relyingParty: , + * "url": , + * "userHandle": , + * "userId": , + * "username:" + * } + */ +void PasskeyExporter::exportSelectedEntry(const Entry* entry, const QString& folder) +{ + const auto fullPath = QString("%1/%2.passkey").arg(folder, Tools::cleanFilename(entry->title())); + if (QFile::exists(fullPath)) { + auto dialogResult = MessageBox::warning(nullptr, + tr("KeePassXC: Passkey Export"), + tr("File \"%1.passkey\" already exists.\n" + "Do you want to overwrite it?\n") + .arg(entry->title()), + MessageBox::Yes | MessageBox::No); + + if (dialogResult != MessageBox::Yes) { + return; + } + } + + QFile passkeyFile(fullPath); + if (!passkeyFile.open(QIODevice::WriteOnly)) { + MessageBox::information( + nullptr, tr("Cannot open file"), tr("Cannot open file \"%1\" for writing.").arg(fullPath)); + return; + } + + QJsonObject passkeyObject; + passkeyObject["relyingParty"] = entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_RELYING_PARTY); + passkeyObject["url"] = entry->url(); + passkeyObject["username"] = entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_USERNAME); + passkeyObject["userId"] = entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_GENERATED_USER_ID); + passkeyObject["userHandle"] = entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_USER_HANDLE); + passkeyObject["privateKey"] = entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_PRIVATE_KEY_PEM); + + QJsonDocument document(passkeyObject); + if (passkeyFile.write(document.toJson()) < 0) { + MessageBox::information( + nullptr, tr("Cannot write to file"), tr("Cannot open file \"%1\" for writing.").arg(fullPath)); + } + + passkeyFile.close(); +} diff --git a/src/gui/passkeys/PasskeyExporter.h b/src/gui/passkeys/PasskeyExporter.h new file mode 100644 index 0000000000..4214cbea33 --- /dev/null +++ b/src/gui/passkeys/PasskeyExporter.h @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2023 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEEPASSXC_PASSKEYEXPORTER_H +#define KEEPASSXC_PASSKEYEXPORTER_H + +#include +#include + +class Entry; + +class PasskeyExporter : public QObject +{ + Q_OBJECT + +public: + explicit PasskeyExporter() = default; + + void showExportDialog(const QList& items); + +private: + void exportSelectedEntry(const Entry* entry, const QString& folder); +}; + +#endif // KEEPASSXC_PASSKEYEXPORTER_H diff --git a/src/gui/passkeys/PasskeyImportDialog.cpp b/src/gui/passkeys/PasskeyImportDialog.cpp new file mode 100644 index 0000000000..2d54ba5bad --- /dev/null +++ b/src/gui/passkeys/PasskeyImportDialog.cpp @@ -0,0 +1,121 @@ +/* + * Copyright (C) 2023 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "PasskeyImportDialog.h" +#include "ui_PasskeyImportDialog.h" + +#include "browser/BrowserService.h" +#include "core/Metadata.h" +#include "gui/MainWindow.h" +#include +#include + +PasskeyImportDialog::PasskeyImportDialog(QWidget* parent) + : QDialog(parent) + , m_ui(new Ui::PasskeyImportDialog()) + , m_useDefaultGroup(true) +{ + setWindowFlags(windowFlags() | Qt::WindowStaysOnTopHint); + + m_ui->setupUi(this); + m_ui->useDefaultGroupCheckbox->setChecked(true); + m_ui->selectGroupComboBox->setEnabled(false); + + connect(m_ui->importButton, SIGNAL(clicked()), SLOT(accept())); + connect(m_ui->cancelButton, SIGNAL(clicked()), SLOT(reject())); + connect(m_ui->selectDatabaseButton, SIGNAL(clicked()), SLOT(selectDatabase())); + connect(m_ui->selectGroupComboBox, SIGNAL(currentIndexChanged(int)), SLOT(changeGroup(int))); + connect(m_ui->useDefaultGroupCheckbox, SIGNAL(stateChanged(int)), SLOT(useDefaultGroupChanged())); +} + +PasskeyImportDialog::~PasskeyImportDialog() +{ +} + +void PasskeyImportDialog::setInfo(const QString& url, const QString& username, const QSharedPointer& database) +{ + m_ui->urlLabel->setText(tr("URL: %1").arg(url)); + m_ui->usernameLabel->setText(tr("Username: %1").arg(username)); + m_ui->selectDatabaseLabel->setText(tr("Database: %1").arg(getDatabaseName(database))); + m_ui->selectGroupLabel->setText(tr("Group:")); + + addGroups(database); + + auto openDatabaseCount = 0; + for (auto dbWidget : getMainWindow()->getOpenDatabases()) { + if (dbWidget && !dbWidget->isLocked()) { + openDatabaseCount++; + } + } + m_ui->selectDatabaseButton->setEnabled(openDatabaseCount > 1); +} + +QSharedPointer PasskeyImportDialog::getSelectedDatabase() +{ + return m_selectedDatabase; +} + +QUuid PasskeyImportDialog::getSelectedGroupUuid() +{ + return m_selectedGroupUuid; +} + +bool PasskeyImportDialog::useDefaultGroup() +{ + return m_useDefaultGroup; +} + +QString PasskeyImportDialog::getDatabaseName(const QSharedPointer& database) const +{ + return QFileInfo(database->filePath()).fileName(); +} + +void PasskeyImportDialog::addGroups(const QSharedPointer& database) +{ + m_ui->selectGroupComboBox->clear(); + for (const auto& group : database->rootGroup()->groupsRecursive(true)) { + if (!group || group->isRecycled() || group == database->metadata()->recycleBin()) { + continue; + } + + m_ui->selectGroupComboBox->addItem(group->fullPath(), group->uuid()); + } +} + +void PasskeyImportDialog::selectDatabase() +{ + auto selectedDatabase = browserService()->selectedDatabase(); + if (!selectedDatabase) { + return; + } + + m_selectedDatabase = selectedDatabase; + m_ui->selectDatabaseLabel->setText(QString("Database: %1").arg(getDatabaseName(m_selectedDatabase))); + + addGroups(m_selectedDatabase); +} + +void PasskeyImportDialog::changeGroup(int index) +{ + m_selectedGroupUuid = m_ui->selectGroupComboBox->itemData(index).value(); +} + +void PasskeyImportDialog::useDefaultGroupChanged() +{ + m_ui->selectGroupComboBox->setEnabled(!m_ui->useDefaultGroupCheckbox->isChecked()); + m_useDefaultGroup = m_ui->useDefaultGroupCheckbox->isChecked(); +} diff --git a/src/gui/passkeys/PasskeyImportDialog.h b/src/gui/passkeys/PasskeyImportDialog.h new file mode 100644 index 0000000000..7b316721e7 --- /dev/null +++ b/src/gui/passkeys/PasskeyImportDialog.h @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2023 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEEPASSXC_PASSKEYIMPORTDIALOG_H +#define KEEPASSXC_PASSKEYIMPORTDIALOG_H + +#include "core/Database.h" +#include "core/Group.h" +#include +#include + +namespace Ui +{ + class PasskeyImportDialog; +} + +class PasskeyImportDialog : public QDialog +{ + Q_OBJECT + +public: + explicit PasskeyImportDialog(QWidget* parent = nullptr); + ~PasskeyImportDialog() override; + + void setInfo(const QString& url, const QString& username, const QSharedPointer& database); + QSharedPointer getSelectedDatabase(); + QUuid getSelectedGroupUuid(); + bool useDefaultGroup(); + +private: + QString getDatabaseName(const QSharedPointer& database) const; + void addGroups(const QSharedPointer& database); + +private slots: + void selectDatabase(); + void changeGroup(int index); + void useDefaultGroupChanged(); + +private: + QScopedPointer m_ui; + QSharedPointer m_selectedDatabase; + QUuid m_selectedGroupUuid; + bool m_useDefaultGroup; +}; + +#endif // KEEPASSXC_PASSKEYIMPORTDIALOG_H diff --git a/src/gui/passkeys/PasskeyImportDialog.ui b/src/gui/passkeys/PasskeyImportDialog.ui new file mode 100755 index 0000000000..ffc80d1419 --- /dev/null +++ b/src/gui/passkeys/PasskeyImportDialog.ui @@ -0,0 +1,174 @@ + + + PasskeyImportDialog + + + + 0 + 0 + 405 + 227 + + + + KeePassXC - Passkey Import + + + + + + + + + true + + + + Do you want to import the Passkey? + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter + + + + + + + URL: %1 + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter + + + + + + + Username: %1 + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter + + + + + + + Use default group (Imported Passkeys) + + + false + + + + + + + + + Group + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + + + Database + + + false + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Select Database + + + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Import Passkey + + + Import + + + true + + + true + + + + + + + Cancel + + + true + + + + + + + + + + diff --git a/src/gui/passkeys/PasskeyImporter.cpp b/src/gui/passkeys/PasskeyImporter.cpp new file mode 100644 index 0000000000..103b1df4e7 --- /dev/null +++ b/src/gui/passkeys/PasskeyImporter.cpp @@ -0,0 +1,139 @@ +/* + * Copyright (C) 2023 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "PasskeyImporter.h" +#include "PasskeyImportDialog.h" +#include "browser/BrowserMessageBuilder.h" +#include "browser/BrowserPasskeys.h" +#include "browser/BrowserService.h" +#include "core/Entry.h" +#include "core/Group.h" +#include "gui/FileDialog.h" +#include "gui/MessageBox.h" +#include +#include + +static const QString IMPORTED_PASSKEYS_GROUP = QStringLiteral("Imported Passkeys"); + +void PasskeyImporter::importPasskey(QSharedPointer& database) +{ + auto filter = QString("%1 (*.passkey);;%2 (*)").arg(tr("Passkey file"), tr("All files")); + auto fileName = + fileDialog()->getOpenFileName(nullptr, tr("Open Passkey file"), FileDialog::getLastDir("passkey"), filter); + if (fileName.isEmpty()) { + return; + } + + FileDialog::saveLastDir("passkey", fileName, true); + + QFile file(fileName); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + MessageBox::information( + nullptr, tr("Cannot open file"), tr("Cannot open file \"%1\" for reading.").arg(fileName)); + return; + } + + importSelectedFile(file, database); +} + +void PasskeyImporter::importSelectedFile(QFile& file, QSharedPointer& database) +{ + const auto fileData = file.readAll(); + const auto passkeyObject = browserMessageBuilder()->getJsonObject(fileData); + if (passkeyObject.isEmpty()) { + MessageBox::information(nullptr, + tr("Cannot import Passkey"), + tr("Cannot import Passkey file \"%1\". Data is missing.").arg(file.fileName())); + return; + } + + const auto relyingParty = passkeyObject["relyingParty"].toString(); + const auto url = passkeyObject["url"].toString(); + const auto username = passkeyObject["username"].toString(); + const auto password = passkeyObject["userId"].toString(); + const auto userHandle = passkeyObject["userHandle"].toString(); + const auto privateKey = passkeyObject["privateKey"].toString(); + + if (relyingParty.isEmpty() || username.isEmpty() || password.isEmpty() || userHandle.isEmpty() + || privateKey.isEmpty()) { + MessageBox::information(nullptr, + tr("Cannot import Passkey"), + tr("Cannot import Passkey file \"%1\". Data is missing.").arg(file.fileName())); + } else if (!privateKey.startsWith("-----BEGIN PRIVATE KEY-----") + || !privateKey.trimmed().endsWith("-----END PRIVATE KEY-----")) { + MessageBox::information( + nullptr, + tr("Cannot import Passkey"), + tr("Cannot import Passkey file \"%1\". Private key is missing or malformed.").arg(file.fileName())); + } else { + showImportDialog(database, url, relyingParty, username, password, userHandle, privateKey); + } +} + +void PasskeyImporter::showImportDialog(QSharedPointer& database, + const QString& url, + const QString& relyingParty, + const QString& username, + const QString& userId, + const QString& userHandle, + const QString& privateKey) +{ + PasskeyImportDialog passkeyImportDialog; + passkeyImportDialog.setInfo(relyingParty, username, database); + + auto ret = passkeyImportDialog.exec(); + if (ret != QDialog::Accepted) { + return; + } + + auto db = passkeyImportDialog.getSelectedDatabase(); + if (!db) { + db = database; + } + + // Group settings. Use default group "Imported Passkeys" if user did not select a specific one. + Group* group = nullptr; + + // Attempt to use the selected group + if (!passkeyImportDialog.useDefaultGroup()) { + auto groupUuid = passkeyImportDialog.getSelectedGroupUuid(); + group = db->rootGroup()->findGroupByUuid(groupUuid); + } + + // Use default group if requested or if the selected group does not exist + if (!group) { + group = getDefaultGroup(db); + } + + browserService()->addPasskeyToGroup( + group, url, relyingParty, relyingParty, username, userId, userHandle, privateKey); +} + +Group* PasskeyImporter::getDefaultGroup(QSharedPointer& database) +{ + auto defaultGroup = database->rootGroup()->findGroupByPath(IMPORTED_PASSKEYS_GROUP); + + // Create the default group if it does not exist + if (!defaultGroup) { + defaultGroup = new Group(); + defaultGroup->setName(IMPORTED_PASSKEYS_GROUP); + defaultGroup->setUuid(QUuid::createUuid()); + defaultGroup->setParent(database->rootGroup()); + } + + return defaultGroup; +} diff --git a/src/gui/passkeys/PasskeyImporter.h b/src/gui/passkeys/PasskeyImporter.h new file mode 100644 index 0000000000..c1523cbc18 --- /dev/null +++ b/src/gui/passkeys/PasskeyImporter.h @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2023 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEEPASSXC_PASSKEYIMPORTER_H +#define KEEPASSXC_PASSKEYIMPORTER_H + +#include "core/Database.h" +#include +#include + +class Entry; + +class PasskeyImporter : public QObject +{ + Q_OBJECT + +public: + explicit PasskeyImporter() = default; + + void importPasskey(QSharedPointer& database); + +private: + void importSelectedFile(QFile& file, QSharedPointer& database); + void showImportDialog(QSharedPointer& database, + const QString& url, + const QString& relyingParty, + const QString& username, + const QString& userId, + const QString& userHandle, + const QString& privateKey); + Group* getDefaultGroup(QSharedPointer& database); +}; + +#endif // KEEPASSXC_PASSKEYIMPORTER_H diff --git a/src/gui/reports/ReportsDialog.cpp b/src/gui/reports/ReportsDialog.cpp index 22a7425d57..bdbeca8a9e 100644 --- a/src/gui/reports/ReportsDialog.cpp +++ b/src/gui/reports/ReportsDialog.cpp @@ -1,5 +1,5 @@ /* - * Copyright (C) 2021 KeePassXC Team + * Copyright (C) 2023 KeePassXC Team * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -25,6 +25,10 @@ #include "ReportsPageBrowserStatistics.h" #include "ReportsWidgetBrowserStatistics.h" #endif +#ifdef WITH_XC_BROWSER_PASSKEYS +#include "ReportsPagePasskeys.h" +#include "ReportsWidgetPasskeys.h" +#endif #include "ReportsWidgetHealthcheck.h" #include "ReportsWidgetHibp.h" @@ -61,6 +65,9 @@ ReportsDialog::ReportsDialog(QWidget* parent) , m_statPage(new ReportsPageStatistics()) #ifdef WITH_XC_BROWSER , m_browserStatPage(new ReportsPageBrowserStatistics()) +#endif +#ifdef WITH_XC_BROWSER_PASSKEYS + , m_passkeysPage(new ReportsPagePasskeys()) #endif , m_editEntryWidget(new EditEntryWidget(this)) { @@ -68,10 +75,13 @@ ReportsDialog::ReportsDialog(QWidget* parent) connect(m_ui->buttonBox, SIGNAL(rejected()), SLOT(reject())); addPage(m_statPage); + addPage(m_healthPage); +#ifdef WITH_XC_BROWSER_PASSKEYS + addPage(m_passkeysPage); +#endif #ifdef WITH_XC_BROWSER addPage(m_browserStatPage); #endif - addPage(m_healthPage); addPage(m_hibpPage); m_ui->stackedWidget->setCurrentIndex(0); @@ -88,6 +98,10 @@ ReportsDialog::ReportsDialog(QWidget* parent) connect(m_browserStatPage->m_browserWidget, SIGNAL(entryActivated(Entry*)), SLOT(entryActivationSignalReceived(Entry*))); +#endif +#ifdef WITH_XC_BROWSER_PASSKEYS + connect( + m_passkeysPage->m_passkeysWidget, SIGNAL(entryActivated(Entry*)), SLOT(entryActivationSignalReceived(Entry*))); #endif connect(m_editEntryWidget, SIGNAL(editFinished(bool)), SLOT(switchToMainView(bool))); } @@ -114,6 +128,15 @@ void ReportsDialog::addPage(QSharedPointer page) m_ui->categoryList->setCurrentCategory(category); } +#ifdef WITH_XC_BROWSER_PASSKEYS +void ReportsDialog::activatePasskeysPage() +{ + m_ui->stackedWidget->setCurrentWidget(m_passkeysPage->m_passkeysWidget); + auto index = m_ui->stackedWidget->currentIndex(); + m_ui->categoryList->setCurrentCategory(index); +} +#endif + void ReportsDialog::reject() { emit editFinished(true); @@ -148,6 +171,11 @@ void ReportsDialog::switchToMainView(bool previousDialogAccepted) if (m_sender == m_browserStatPage->m_browserWidget) { m_browserStatPage->m_browserWidget->calculateBrowserStatistics(); } +#endif +#ifdef WITH_XC_BROWSER_PASSKEYS + if (m_sender == m_passkeysPage->m_passkeysWidget) { + m_passkeysPage->m_passkeysWidget->updateEntries(); + } #endif } diff --git a/src/gui/reports/ReportsDialog.h b/src/gui/reports/ReportsDialog.h index 915c20eb99..6400787b44 100644 --- a/src/gui/reports/ReportsDialog.h +++ b/src/gui/reports/ReportsDialog.h @@ -1,5 +1,5 @@ /* - * Copyright (C) 2021 KeePassXC Team + * Copyright (C) 2023 KeePassXC Team * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -32,6 +32,9 @@ class ReportsPageStatistics; #ifdef WITH_XC_BROWSER class ReportsPageBrowserStatistics; #endif +#ifdef WITH_XC_BROWSER_PASSKEYS +class ReportsPagePasskeys; +#endif namespace Ui { @@ -60,6 +63,9 @@ class ReportsDialog : public DialogyWidget void load(const QSharedPointer& db); void addPage(QSharedPointer page); +#ifdef WITH_XC_BROWSER_PASSKEYS + void activatePasskeysPage(); +#endif signals: void editFinished(bool accepted); @@ -77,6 +83,9 @@ private slots: const QSharedPointer m_statPage; #ifdef WITH_XC_BROWSER const QSharedPointer m_browserStatPage; +#endif +#ifdef WITH_XC_BROWSER_PASSKEYS + const QSharedPointer m_passkeysPage; #endif QPointer m_editEntryWidget; QWidget* m_sender = nullptr; diff --git a/src/gui/reports/ReportsPagePasskeys.cpp b/src/gui/reports/ReportsPagePasskeys.cpp new file mode 100644 index 0000000000..01af722664 --- /dev/null +++ b/src/gui/reports/ReportsPagePasskeys.cpp @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2023 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "ReportsPagePasskeys.h" +#include "ReportsWidgetPasskeys.h" +#include "gui/Icons.h" + +ReportsPagePasskeys::ReportsPagePasskeys() + : m_passkeysWidget(new ReportsWidgetPasskeys()) +{ +} + +QString ReportsPagePasskeys::name() +{ + return QObject::tr("Passkeys"); +} + +QIcon ReportsPagePasskeys::icon() +{ + return icons()->icon("passkey"); +} + +QWidget* ReportsPagePasskeys::createWidget() +{ + return m_passkeysWidget; +} + +void ReportsPagePasskeys::loadSettings(QWidget* widget, QSharedPointer db) +{ + const auto settingsWidget = reinterpret_cast(widget); + settingsWidget->loadSettings(db); +} + +void ReportsPagePasskeys::saveSettings(QWidget* widget) +{ + const auto settingsWidget = reinterpret_cast(widget); + settingsWidget->saveSettings(); +} diff --git a/src/gui/reports/ReportsPagePasskeys.h b/src/gui/reports/ReportsPagePasskeys.h new file mode 100644 index 0000000000..8be0aa7d08 --- /dev/null +++ b/src/gui/reports/ReportsPagePasskeys.h @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2023 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEEPASSXC_REPORTSPAGEPASSKEYS_H +#define KEEPASSXC_REPORTSPAGEPASSKEYS_H + +#include "ReportsDialog.h" +#include "ReportsWidgetPasskeys.h" + +class ReportsWidgetBrowserStatistics; + +class ReportsPagePasskeys : public IReportsPage +{ +public: + ReportsWidgetPasskeys* m_passkeysWidget; + + ReportsPagePasskeys(); + + QString name() override; + QIcon icon() override; + QWidget* createWidget() override; + void loadSettings(QWidget* widget, QSharedPointer db) override; + void saveSettings(QWidget* widget) override; +}; + +#endif // KEEPASSXC_REPORTSPAGEPASSKEYS_H diff --git a/src/gui/reports/ReportsWidgetBrowserStatistics.cpp b/src/gui/reports/ReportsWidgetBrowserStatistics.cpp index 1a3ad5d7ef..a7724a7e41 100644 --- a/src/gui/reports/ReportsWidgetBrowserStatistics.cpp +++ b/src/gui/reports/ReportsWidgetBrowserStatistics.cpp @@ -112,8 +112,8 @@ ReportsWidgetBrowserStatistics::ReportsWidgetBrowserStatistics(QWidget* parent) connect( m_ui->browserStatisticsTableView, SIGNAL(doubleClicked(QModelIndex)), SLOT(emitEntryActivated(QModelIndex))); connect(m_ui->showEntriesWithUrlOnlyCheckBox, SIGNAL(stateChanged(int)), this, SLOT(calculateBrowserStatistics())); - connect(m_ui->showConnectedOnlyCheckBox, SIGNAL(stateChanged(int)), this, SLOT(calculateBrowserStatistics())); - connect(m_ui->excludeExpired, SIGNAL(stateChanged(int)), this, SLOT(calculateBrowserStatistics())); + connect(m_ui->showAllowDenyCheckBox, SIGNAL(stateChanged(int)), this, SLOT(calculateBrowserStatistics())); + connect(m_ui->showExpired, SIGNAL(stateChanged(int)), this, SLOT(calculateBrowserStatistics())); new QShortcut(Qt::Key_Delete, this, SLOT(deleteSelectedEntries())); } @@ -144,6 +144,9 @@ void ReportsWidgetBrowserStatistics::addStatisticsRow(bool hasUrls, if (excluded) { title.append(tr(" (Excluded)")); } + if (entry->isExpired()) { + title.append(tr(" (Expired)")); + } auto row = QList(); row << new QStandardItem(Icons::entryIconPixmap(entry), title); @@ -196,16 +199,15 @@ void ReportsWidgetBrowserStatistics::calculateBrowserStatistics() const QScopedPointer browserStatistics( AsyncTask::runAndWaitForFuture([this] { return new BrowserStatistics(m_db); })); - const auto showExcluded = m_ui->showConnectedOnlyCheckBox->isChecked(); + const auto showExpired = m_ui->showExpired->isChecked(); const auto showEntriesWithUrlOnly = m_ui->showEntriesWithUrlOnlyCheckBox->isChecked(); - const auto showOnlyEntriesWithSettings = m_ui->showConnectedOnlyCheckBox->isChecked(); + const auto showOnlyEntriesWithSettings = m_ui->showAllowDenyCheckBox->isChecked(); // Display the entries m_rowToEntry.clear(); for (const auto& item : browserStatistics->items()) { - auto excluded = item->exclude || (item->entry->isExpired() && m_ui->excludeExpired->isChecked()); - if (excluded && !showExcluded) { - // Exclude this entry from the report + // Check if the entry should be displayed + if (!showExpired && item->entry->isExpired()) { continue; } diff --git a/src/gui/reports/ReportsWidgetBrowserStatistics.ui b/src/gui/reports/ReportsWidgetBrowserStatistics.ui index 4236da6e17..9f631bef65 100644 --- a/src/gui/reports/ReportsWidgetBrowserStatistics.ui +++ b/src/gui/reports/ReportsWidgetBrowserStatistics.ui @@ -10,7 +10,7 @@ 379 - + 0 @@ -55,23 +55,26 @@ - + - Exclude expired entries from the report + Only show entries that have a URL + + + true - + - Show only entries which have URL set + Only show entries that have been explicitly allowed or denied - + - Show only entries which have browser settings in custom data + Show expired entries @@ -91,9 +94,8 @@ browserStatisticsTableView - excludeExpired showEntriesWithUrlOnlyCheckBox - showConnectedOnlyCheckBox + showAllowDenyCheckBox diff --git a/src/gui/reports/ReportsWidgetHealthcheck.cpp b/src/gui/reports/ReportsWidgetHealthcheck.cpp index 19da39e02e..53cf150bcd 100644 --- a/src/gui/reports/ReportsWidgetHealthcheck.cpp +++ b/src/gui/reports/ReportsWidgetHealthcheck.cpp @@ -64,16 +64,16 @@ namespace return m_items; } - bool anyKnownBad() const + bool anyExcludedEntries() const { - return m_anyKnownBad; + return m_anyExcludedEntries; } private: QSharedPointer m_db; HealthChecker m_checker; QList> m_items; - bool m_anyKnownBad = false; + bool m_anyExcludedEntries = false; }; class ReportSortProxyModel : public QSortFilterProxyModel @@ -121,7 +121,7 @@ Health::Health(QSharedPointer db) // Evaluate this entry const auto item = QSharedPointer(new Item(group, entry, m_checker.evaluate(entry))); if (item->exclude) { - m_anyKnownBad = true; + m_anyExcludedEntries = true; } // Add entry if its password isn't at least "good" @@ -152,8 +152,8 @@ ReportsWidgetHealthcheck::ReportsWidgetHealthcheck(QWidget* parent) connect(m_ui->healthcheckTableView, SIGNAL(customContextMenuRequested(QPoint)), SLOT(customMenuRequested(QPoint))); connect(m_ui->healthcheckTableView, SIGNAL(doubleClicked(QModelIndex)), SLOT(emitEntryActivated(QModelIndex))); - connect(m_ui->showKnownBadCheckBox, SIGNAL(stateChanged(int)), this, SLOT(calculateHealth())); - connect(m_ui->excludeExpired, SIGNAL(stateChanged(int)), this, SLOT(calculateHealth())); + connect(m_ui->showExcluded, SIGNAL(stateChanged(int)), this, SLOT(calculateHealth())); + connect(m_ui->showExpired, SIGNAL(stateChanged(int)), this, SLOT(calculateHealth())); new QShortcut(Qt::Key_Delete, this, SLOT(deleteSelectedEntries())); } @@ -163,7 +163,7 @@ ReportsWidgetHealthcheck::~ReportsWidgetHealthcheck() = default; void ReportsWidgetHealthcheck::addHealthRow(QSharedPointer health, Group* group, Entry* entry, - bool knownBad) + bool excluded) { QString descr, tip; QColor qualityColor; @@ -195,9 +195,12 @@ void ReportsWidgetHealthcheck::addHealthRow(QSharedPointer healt } auto title = entry->title(); - if (knownBad) { + if (excluded) { title.append(tr(" (Excluded)")); } + if (entry->isExpired()) { + title.append(tr(" (Expired)")); + } auto row = QList(); row << new QStandardItem(descr); @@ -215,7 +218,7 @@ void ReportsWidgetHealthcheck::addHealthRow(QSharedPointer healt // Set tooltips row[0]->setToolTip(tip); - if (knownBad) { + if (excluded) { row[1]->setToolTip(tr("This entry is being excluded from reports")); } row[4]->setToolTip(health->scoreDetails()); @@ -255,15 +258,12 @@ void ReportsWidgetHealthcheck::calculateHealth() // Perform the health check const QScopedPointer health(AsyncTask::runAndWaitForFuture([this] { return new Health(m_db); })); - // Display entries that are marked as "known bad"? - const auto showExcluded = m_ui->showKnownBadCheckBox->isChecked(); - // Display the entries m_rowToEntry.clear(); for (const auto& item : health->items()) { - auto excluded = item->exclude || (item->entry->isExpired() && m_ui->excludeExpired->isChecked()); - if (excluded && !showExcluded) { - // Exclude this entry from the report + // Check if the entry should be displayed + if ((!m_ui->showExcluded->isChecked() && item->exclude) + || (!m_ui->showExpired->isChecked() && item->entry->isExpired())) { continue; } @@ -283,13 +283,8 @@ void ReportsWidgetHealthcheck::calculateHealth() m_ui->healthcheckTableView->resizeColumnsToContents(); m_ui->healthcheckTableView->horizontalHeader()->setSectionResizeMode(0, QHeaderView::Fixed); - // Show the "show known bad entries" checkbox if there's any known - // bad entry in the database. - if (health->anyKnownBad()) { - m_ui->showKnownBadCheckBox->show(); - } else { - m_ui->showKnownBadCheckBox->hide(); - } + // Only show the "show excluded" checkbox if there are any excluded entries in the database + m_ui->showExcluded->setVisible(health->anyExcludedEntries()); } void ReportsWidgetHealthcheck::emitEntryActivated(const QModelIndex& index) diff --git a/src/gui/reports/ReportsWidgetHealthcheck.h b/src/gui/reports/ReportsWidgetHealthcheck.h index 2046326a11..21d121b00b 100644 --- a/src/gui/reports/ReportsWidgetHealthcheck.h +++ b/src/gui/reports/ReportsWidgetHealthcheck.h @@ -56,7 +56,7 @@ public slots: void deleteSelectedEntries(); private: - void addHealthRow(QSharedPointer, Group*, Entry*, bool knownBad); + void addHealthRow(QSharedPointer, Group*, Entry*, bool excluded); QScopedPointer m_ui; diff --git a/src/gui/reports/ReportsWidgetHealthcheck.ui b/src/gui/reports/ReportsWidgetHealthcheck.ui index e2ed44e1b8..5bc2aa1184 100644 --- a/src/gui/reports/ReportsWidgetHealthcheck.ui +++ b/src/gui/reports/ReportsWidgetHealthcheck.ui @@ -55,16 +55,16 @@ - + - Exclude expired entries from the report + Show expired entries - + - Also show entries that have been excluded from reports + Show entries that have been excluded from reports @@ -84,7 +84,7 @@ healthcheckTableView - showKnownBadCheckBox + showExcluded diff --git a/src/gui/reports/ReportsWidgetPasskeys.cpp b/src/gui/reports/ReportsWidgetPasskeys.cpp new file mode 100644 index 0000000000..a50576be6a --- /dev/null +++ b/src/gui/reports/ReportsWidgetPasskeys.cpp @@ -0,0 +1,294 @@ +/* + * Copyright (C) 2023 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "ReportsWidgetPasskeys.h" +#include "ui_ReportsWidgetPasskeys.h" + +#include "browser/BrowserPasskeys.h" +#include "core/AsyncTask.h" +#include "core/Group.h" +#include "core/Metadata.h" +#include "gui/GuiTools.h" +#include "gui/Icons.h" +#include "gui/passkeys/PasskeyExporter.h" +#include "gui/passkeys/PasskeyImporter.h" +#include "gui/styles/StateColorPalette.h" + +#include +#include +#include +#include + +namespace +{ + class PasskeyList + { + public: + struct Item + { + QPointer group; + QPointer entry; + + Item(Group* g, Entry* e) + : group(g) + , entry(e) + { + } + }; + + explicit PasskeyList(const QSharedPointer&); + + const QList>& items() const + { + return m_items; + } + + private: + QSharedPointer m_db; + QList> m_items; + }; +} // namespace + +PasskeyList::PasskeyList(const QSharedPointer& db) + : m_db(db) +{ + for (auto group : db->rootGroup()->groupsRecursive(true)) { + // Skip recycle bin + if (group->isRecycled()) { + continue; + } + + for (auto entry : group->entries()) { + if (entry->isRecycled() || !entry->attributes()->hasKey(BrowserPasskeys::KPEX_PASSKEY_PRIVATE_KEY_PEM)) { + continue; + } + + const auto item = QSharedPointer(new Item(group, entry)); + m_items.append(item); + } + } +} + +ReportsWidgetPasskeys::ReportsWidgetPasskeys(QWidget* parent) + : QWidget(parent) + , m_ui(new Ui::ReportsWidgetPasskeys()) + , m_referencesModel(new QStandardItemModel(this)) + , m_modelProxy(new QSortFilterProxyModel(this)) +{ + m_ui->setupUi(this); + + m_modelProxy->setSourceModel(m_referencesModel.data()); + m_modelProxy->setSortLocaleAware(true); + m_ui->passkeysTableView->setModel(m_modelProxy.data()); + m_ui->passkeysTableView->horizontalHeader()->setSectionResizeMode(QHeaderView::Interactive); + m_ui->passkeysTableView->verticalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents); + + connect(m_ui->passkeysTableView, SIGNAL(customContextMenuRequested(QPoint)), SLOT(customMenuRequested(QPoint))); + connect(m_ui->passkeysTableView, SIGNAL(doubleClicked(QModelIndex)), SLOT(emitEntryActivated(QModelIndex))); + connect(m_ui->passkeysTableView->selectionModel(), + SIGNAL(selectionChanged(QItemSelection, QItemSelection)), + this, + SLOT(selectionChanged())); + connect(m_ui->showExpired, SIGNAL(stateChanged(int)), this, SLOT(updateEntries())); + connect(m_ui->exportButton, SIGNAL(clicked(bool)), this, SLOT(exportPasskey())); + connect(m_ui->importButton, SIGNAL(clicked(bool)), this, SLOT(importPasskey())); + + m_ui->exportButton->setEnabled(false); + + new QShortcut(Qt::Key_Delete, this, SLOT(deleteSelectedEntries())); +} + +ReportsWidgetPasskeys::~ReportsWidgetPasskeys() +{ +} + +void ReportsWidgetPasskeys::addPasskeyRow(Group* group, Entry* entry) +{ + StateColorPalette statePalette; + + auto urlList = entry->getAllUrls(); + auto urlToolTip = tr("List of entry URLs"); + + auto title = entry->title(); + if (entry->isExpired()) { + title.append(tr(" (Expired)")); + } + + auto row = QList(); + row << new QStandardItem(Icons::entryIconPixmap(entry), title); + row << new QStandardItem(Icons::groupIconPixmap(group), group->hierarchy().join("/")); + row << new QStandardItem(entry->username()); + row << new QStandardItem(entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_RELYING_PARTY)); + row << new QStandardItem(urlList.join('\n')); + + // Set tooltips + row[2]->setToolTip(urlToolTip); + + // Store entry pointer per table row (used in double click handler) + m_referencesModel->appendRow(row); + m_rowToEntry.append({group, entry}); +} + +void ReportsWidgetPasskeys::loadSettings(QSharedPointer db) +{ + m_db = std::move(db); + m_entriesUpdated = false; + m_referencesModel->clear(); + m_rowToEntry.clear(); + + auto row = QList(); + row << new QStandardItem(tr("Please wait, list of entries with Passkeys is being updated…")); + m_referencesModel->appendRow(row); +} + +void ReportsWidgetPasskeys::showEvent(QShowEvent* event) +{ + QWidget::showEvent(event); + + if (!m_entriesUpdated) { + // Perform stats calculation on next event loop to allow widget to appear + m_entriesUpdated = true; + QTimer::singleShot(0, this, SLOT(updateEntries())); + } +} + +void ReportsWidgetPasskeys::updateEntries() +{ + m_referencesModel->clear(); + + // Perform the statistics check + const QScopedPointer browserStatistics( + AsyncTask::runAndWaitForFuture([this] { return new PasskeyList(m_db); })); + + // Display the entries + m_rowToEntry.clear(); + for (const auto& item : browserStatistics->items()) { + // Exclude expired entries from report if not requested + if (!m_ui->showExpired->isChecked() && item->entry->isExpired()) { + continue; + } + + addPasskeyRow(item->group, item->entry); + } + + // Set the table header + if (m_referencesModel->rowCount() == 0) { + m_referencesModel->setHorizontalHeaderLabels(QStringList() << tr("No entries with Passkeys.")); + } else { + m_referencesModel->setHorizontalHeaderLabels(QStringList() << tr("Title") << tr("Path") << tr("Username") + << tr("Relying Party") << tr("URLs")); + m_ui->passkeysTableView->sortByColumn(0, Qt::AscendingOrder); + } + + m_ui->passkeysTableView->resizeColumnsToContents(); +} + +void ReportsWidgetPasskeys::emitEntryActivated(const QModelIndex& index) +{ + if (!index.isValid()) { + return; + } + + auto mappedIndex = m_modelProxy->mapToSource(index); + const auto row = m_rowToEntry[mappedIndex.row()]; + const auto group = row.first; + const auto entry = row.second; + + if (group && entry) { + emit entryActivated(entry); + } +} + +void ReportsWidgetPasskeys::customMenuRequested(QPoint pos) +{ + auto selected = m_ui->passkeysTableView->selectionModel()->selectedRows(); + if (selected.isEmpty()) { + return; + } + + // Create the context menu + const auto menu = new QMenu(this); + + // Create the "edit entry" menu item (only if 1 row is selected) + if (selected.size() == 1) { + const auto edit = new QAction(icons()->icon("entry-edit"), tr("Edit Entry…"), this); + menu->addAction(edit); + connect(edit, &QAction::triggered, edit, [this, selected] { + auto row = m_modelProxy->mapToSource(selected[0]).row(); + auto entry = m_rowToEntry[row].second; + emit entryActivated(entry); + }); + } + + // Create the "delete entry" menu item + const auto delEntry = new QAction(icons()->icon("entry-delete"), tr("Delete Entry(s)…", "", selected.size()), this); + menu->addAction(delEntry); + connect(delEntry, &QAction::triggered, this, &ReportsWidgetPasskeys::deleteSelectedEntries); + + // Show the context menu + menu->popup(m_ui->passkeysTableView->viewport()->mapToGlobal(pos)); +} + +void ReportsWidgetPasskeys::saveSettings() +{ + // Nothing to do - the tab is passive +} + +void ReportsWidgetPasskeys::deleteSelectedEntries() +{ + auto selectedEntries = getSelectedEntries(); + bool permanent = !m_db->metadata()->recycleBinEnabled(); + + if (GuiTools::confirmDeleteEntries(this, selectedEntries, permanent)) { + GuiTools::deleteEntriesResolveReferences(this, selectedEntries, permanent); + } + + updateEntries(); +} + +QList ReportsWidgetPasskeys::getSelectedEntries() +{ + QList selectedEntries; + for (auto index : m_ui->passkeysTableView->selectionModel()->selectedRows()) { + auto row = m_modelProxy->mapToSource(index).row(); + auto entry = m_rowToEntry[row].second; + if (entry) { + selectedEntries << entry; + } + } + + return selectedEntries; +} + +void ReportsWidgetPasskeys::selectionChanged() +{ + m_ui->exportButton->setEnabled(!m_ui->passkeysTableView->selectionModel()->selectedIndexes().isEmpty()); +} + +void ReportsWidgetPasskeys::importPasskey() +{ + PasskeyImporter passkeyImporter; + passkeyImporter.importPasskey(m_db); + + updateEntries(); +} + +void ReportsWidgetPasskeys::exportPasskey() +{ + PasskeyExporter passkeyExporter; + passkeyExporter.showExportDialog(getSelectedEntries()); +} diff --git a/src/gui/reports/ReportsWidgetPasskeys.h b/src/gui/reports/ReportsWidgetPasskeys.h new file mode 100644 index 0000000000..3d0593350c --- /dev/null +++ b/src/gui/reports/ReportsWidgetPasskeys.h @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2023 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEEPASSXC_REPORTSWIDGETPASSKEYS_H +#define KEEPASSXC_REPORTSWIDGETPASSKEYS_H + +#include "gui/entry/EntryModel.h" +#include + +class Database; +class Entry; +class Group; +class PasswordHealth; +class QSortFilterProxyModel; +class QStandardItemModel; + +namespace Ui +{ + class ReportsWidgetPasskeys; +} + +class ReportsWidgetPasskeys : public QWidget +{ + Q_OBJECT +public: + explicit ReportsWidgetPasskeys(QWidget* parent = nullptr); + ~ReportsWidgetPasskeys() override; + + void loadSettings(QSharedPointer db); + void saveSettings(); + +protected: + void showEvent(QShowEvent* event) override; + +signals: + void entryActivated(Entry*); + +public slots: + void updateEntries(); + void emitEntryActivated(const QModelIndex& index); + void customMenuRequested(QPoint); + void deleteSelectedEntries(); + +private slots: + void selectionChanged(); + void importPasskey(); + void exportPasskey(); + +private: + void addPasskeyRow(Group*, Entry*); + QList getSelectedEntries(); + + QScopedPointer m_ui; + + bool m_entriesUpdated = false; + QScopedPointer m_referencesModel; + QScopedPointer m_modelProxy; + QSharedPointer m_db; + QList> m_rowToEntry; +}; + +#endif // KEEPASSXC_REPORTSWIDGETPASSKEYS_H diff --git a/src/gui/reports/ReportsWidgetPasskeys.ui b/src/gui/reports/ReportsWidgetPasskeys.ui new file mode 100644 index 0000000000..c1e321fc8b --- /dev/null +++ b/src/gui/reports/ReportsWidgetPasskeys.ui @@ -0,0 +1,102 @@ + + + ReportsWidgetPasskeys + + + + 0 + 0 + 505 + 379 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::CustomContextMenu + + + QAbstractScrollArea::AdjustToContents + + + QAbstractItemView::NoEditTriggers + + + false + + + true + + + QAbstractItemView::SelectRows + + + Qt::ElideRight + + + true + + + true + + + false + + + + + + + Show expired entries + + + + + + + + + Import + + + + + + + Export + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + diff --git a/src/gui/styles/base/basestyle.qss b/src/gui/styles/base/basestyle.qss index fa68589895..2c98c48087 100644 --- a/src/gui/styles/base/basestyle.qss +++ b/src/gui/styles/base/basestyle.qss @@ -25,6 +25,10 @@ QCheckBox, QRadioButton { spacing: 10px; } +ReportsDialog QTableView::item { + padding: 4px; +} + DatabaseWidget, DatabaseWidget #groupView, DatabaseWidget #tagView { background-color: palette(window); border: none; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 1abe869a41..4c311b69ef 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1,4 +1,4 @@ -# Copyright (C) 2018 KeePassXC Team +# Copyright (C) 2023 KeePassXC Team # Copyright (C) 2010 Felix Geyer # # This program is free software: you can redistribute it and/or modify @@ -233,6 +233,17 @@ endif() if(WITH_XC_BROWSER) add_unit_test(NAME testbrowser SOURCES TestBrowser.cpp LIBS ${TEST_LIBRARIES}) + + if(WITH_XC_BROWSER_PASSKEYS) + # Prevent duplicate linking with macOS + if(APPLE) + add_unit_test(NAME testpasskeys SOURCES TestPasskeys.cpp + LIBS ${TEST_LIBRARIES}) + else() + add_unit_test(NAME testpasskeys SOURCES TestPasskeys.cpp + LIBS keepassxcbrowser ${TEST_LIBRARIES}) + endif() + endif() endif() add_unit_test(NAME testcli SOURCES TestCli.cpp diff --git a/tests/TestPasskeys.cpp b/tests/TestPasskeys.cpp new file mode 100644 index 0000000000..556e287d70 --- /dev/null +++ b/tests/TestPasskeys.cpp @@ -0,0 +1,471 @@ +/* + * Copyright (C) 2023 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "TestPasskeys.h" +#include "browser/BrowserCbor.h" +#include "browser/BrowserMessageBuilder.h" +#include "browser/BrowserService.h" +#include "crypto/Crypto.h" + +#include +#include +#include +#include + +using namespace Botan::Sodium; + +QTEST_GUILESS_MAIN(TestPasskeys) + +// Register request +// clang-format off +const QString PublicKeyCredentialOptions = R"( + { + "attestation": "none", + "authenticatorSelection": { + "residentKey": "preferred", + "requireResidentKey": false, + "userVerification": "required" + }, + "challenge": "lVeHzVxWsr8MQxMkZF0ti6FXhdgMljqKzgA-q_zk2Mnii3eJ47VF97sqUoYktVC85WAZ1uIASm-a_lDFZwsLfw", + "pubKeyCredParams": [ + { + "type": "public-key", + "alg": -7 + }, + { + "type": "public-key", + "alg": -257 + } + ], + "rp": { + "name": "webauthn.io", + "id": "webauthn.io" + }, + "timeout": 60000, + "excludeCredentials": [], + "user": { + "displayName": "Test User", + "id": "VkdWemRDQlZjMlZ5", + "name": "Test User" + } + } +)"; + +// Register response +const QString PublicKeyCredential = R"( + { + "authenticatorAttachment": "platform", + "id": "yrzFJ5lwcpTwYMOdXSmxF5b5cYQlqBMzbbU_d-oFLO8", + "rawId": "cabcc52799707294f060c39d5d29b11796f9718425a813336db53f77ea052cef", + "response": { + "attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVikdKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvBFAAAAAAECAwQFBgcIAQIDBAUGBwgAIMq8xSeZcHKU8GDDnV0psReW-XGEJagTM221P3fqBSzvpQECAyYgASFYIAbsrzRbYpFhbRlZA6ZQKsoxxJWoaeXwh-XUuDLNCIXdIlgg4u5_6Q8O6R0Hg0oDCdtCJLEL0yX_GDLhU5m3HUIE54M", + "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoibFZlSHpWeFdzcjhNUXhNa1pGMHRpNkZYaGRnTWxqcUt6Z0EtcV96azJNbmlpM2VKNDdWRjk3c3FVb1lrdFZDODVXQVoxdUlBU20tYV9sREZad3NMZnciLCJvcmlnaW4iOiJodHRwczovL3dlYmF1dGhuLmlvIiwiY3Jvc3NPcmlnaW4iOmZhbHNlfQ" + }, + "type": "public-key" + } +)"; + +// Get request +const QString PublicKeyCredentialRequestOptions = R"( + { + "allowCredentials": [ + { + "id": "yrzFJ5lwcpTwYMOdXSmxF5b5cYQlqBMzbbU_d-oFLO8", + "transports": ["internal"], + "type": "public-key" + } + ], + "challenge": "9z36vTfQTL95Lf7WnZgyte7ohGeF-XRiLxkL-LuGU1zopRmMIUA1LVwzGpyIm1fOBn1QnRa0QH27ADAaJGHysQ", + "rpId": "webauthn.io", + "timeout": 60000, + "userVerification": "required" + } +)"; +// clang-format on + +void TestPasskeys::initTestCase() +{ + QVERIFY(Crypto::init()); +} + +void TestPasskeys::init() +{ +} + +void TestPasskeys::testBase64WithHexStrings() +{ + const size_t bufSize = 64; + unsigned char buf[bufSize] = {31, 141, 30, 29, 142, 73, 5, 239, 242, 84, 187, 202, 40, 54, 15, 223, + 201, 0, 108, 109, 209, 104, 207, 239, 160, 89, 208, 117, 134, 66, 42, 12, + 31, 66, 163, 248, 221, 88, 241, 164, 6, 55, 182, 97, 186, 243, 162, 162, + 81, 220, 55, 60, 93, 207, 170, 222, 56, 234, 227, 45, 115, 175, 138, 182}; + + auto base64FromArray = browserMessageBuilder()->getBase64FromArray(reinterpret_cast(buf), bufSize); + QCOMPARE(base64FromArray, + QString("H40eHY5JBe_yVLvKKDYP38kAbG3RaM_voFnQdYZCKgwfQqP43VjxpAY3tmG686KiUdw3PF3Pqt446uMtc6-Ktg")); + + auto arrayFromBase64 = browserMessageBuilder()->getArrayFromBase64(base64FromArray); + QCOMPARE(arrayFromBase64.size(), bufSize); + + for (size_t i = 0; i < bufSize; i++) { + QCOMPARE(static_cast(arrayFromBase64.at(i)), buf[i]); + } + + auto randomDataBase64 = browserMessageBuilder()->getRandomBytesAsBase64(24); + QCOMPARE(randomDataBase64.isEmpty(), false); +} + +void TestPasskeys::testDecodeResponseData() +{ + const auto publicKeyCredential = browserMessageBuilder()->getJsonObject(PublicKeyCredential.toUtf8()); + auto response = publicKeyCredential["response"].toObject(); + auto clientDataJson = response["clientDataJSON"].toString(); + auto attestationObject = response["attestationObject"].toString(); + + QVERIFY(!clientDataJson.isEmpty()); + QVERIFY(!attestationObject.isEmpty()); + + // Parse clientDataJSON + auto clientDataByteArray = browserMessageBuilder()->getArrayFromBase64(clientDataJson); + auto clientDataJsonObject = browserMessageBuilder()->getJsonObject(clientDataByteArray); + QCOMPARE(clientDataJsonObject["challenge"], + QString("lVeHzVxWsr8MQxMkZF0ti6FXhdgMljqKzgA-q_zk2Mnii3eJ47VF97sqUoYktVC85WAZ1uIASm-a_lDFZwsLfw")); + QCOMPARE(clientDataJsonObject["origin"], QString("https://webauthn.io")); + QCOMPARE(clientDataJsonObject["type"], QString("webauthn.create")); + + // Parse attestationObject (CBOR decoding needed) + BrowserCbor browserCbor; + auto attestationByteArray = browserMessageBuilder()->getArrayFromBase64(attestationObject); + auto attestationJsonObject = browserCbor.getJsonFromCborData(attestationByteArray); + + // Parse authData + auto authDataJsonObject = attestationJsonObject["authData"].toString(); + auto authDataArray = browserMessageBuilder()->getArrayFromBase64(authDataJsonObject); + QVERIFY(authDataArray.size() >= 37); + + auto authData = browserPasskeys()->parseAuthData(authDataArray); + auto credentialData = authData["credentialData"].toObject(); + auto flags = authData["flags"].toObject(); + auto publicKey = credentialData["publicKey"].toObject(); + + // The attestationObject should include the same ID after decoding with the response root + QCOMPARE(credentialData["credentialId"].toString(), publicKeyCredential["id"].toString()); + QCOMPARE(credentialData["aaguid"].toString(), QString("AQIDBAUGBwgBAgMEBQYHCA")); + QCOMPARE(authData["rpIdHash"].toString(), QString("dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvA")); + QCOMPARE(flags["AT"], true); + QCOMPARE(flags["UP"], true); + QCOMPARE(publicKey["1"], 2); + QCOMPARE(publicKey["3"], -7); + QCOMPARE(publicKey["-1"], 1); + QCOMPARE(publicKey["-2"], QString("BuyvNFtikWFtGVkDplAqyjHElahp5fCH5dS4Ms0Ihd0")); + QCOMPARE(publicKey["-3"], QString("4u5_6Q8O6R0Hg0oDCdtCJLEL0yX_GDLhU5m3HUIE54M")); +} + +void TestPasskeys::testLoadingECPrivateKeyFromPem() +{ + const auto publicKeyCredentialRequestOptions = + browserMessageBuilder()->getJsonObject(PublicKeyCredentialRequestOptions.toUtf8()); + const auto privateKeyPem = QString("-----BEGIN PRIVATE KEY-----" + "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg5DX2R6I37nMSZqCp" + "XfHlE3UeitkGGE03FqGsdfxIBoOhRANCAAQG7K80W2KRYW0ZWQOmUCrKMcSVqGnl" + "8Ifl1LgyzQiF3eLuf+kPDukdB4NKAwnbQiSxC9Ml/xgy4VOZtx1CBOeD" + "-----END PRIVATE KEY-----"); + + const auto authenticatorData = + browserMessageBuilder()->getArrayFromBase64("dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvAFAAAAAA"); + const auto clientData = browserMessageBuilder()->getArrayFromBase64( + "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiOXozNnZUZlFUTDk1TGY3V25aZ3l0ZTdvaEdlRi1YUmlMeGtMLUx1R1Uxem9wUm" + "1NSVVBMUxWd3pHcHlJbTFmT0JuMVFuUmEwUUgyN0FEQWFKR0h5c1EiLCJvcmlnaW4iOiJodHRwczovL3dlYmF1dGhuLmlvIiwiY3Jvc3NPcmln" + "aW4iOmZhbHNlfQ"); + + const auto signature = browserPasskeys()->buildSignature(authenticatorData, clientData, privateKeyPem); + QCOMPARE( + browserMessageBuilder()->getBase64FromArray(signature), + QString("MEYCIQCpbDaYJ4b2ofqWBxfRNbH3XCpsyao7Iui5lVuJRU9HIQIhAPl5moNZgJu5zmurkKK_P900Ct6wd3ahVIqCEqTeeRdE")); +} + +void TestPasskeys::testLoadingRSAPrivateKeyFromPem() +{ + const auto privateKeyPem = QString("-----BEGIN PRIVATE KEY-----" + "MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQC5OHjBHQaRfxxX\n4WHRmqq7e7JgT" + "FRs1bd4dIOFAOZnhNE3vAg2IF5VurmeB+9ye9xh7frw8ubrL0cv\nsBWiJfN5CY3SYGRLbGTtBC0fZ6" + "OhhhjwvVM1GW6nVeRU66atzuo4NBfYXJWIYECd\npRBU4+xsDL4vJnn1mj05+v/Tqp6Uo1HrEPx9+Dc" + "oYJD+cw7+OQ83XeGmjD+Dtm5z\nNIyYdweaafVR4PEUlB3CYZuOq9xcpxay3ps2MuYT1zGoiQqk6fla" + "d+0tBWGY8Lwp\nCVulXCv7ljNJ4gxgQtOqWX8j2hC0hBxeqNYDYbrkECid3TsMTEMcV5uaVJXULg4t" + "\nn6UItA11AgMBAAECggEAC3B0WBxHuieIfllOOOC4H9/7S7fDH2f7+W2cFtQ6pqo9\nCq0WBmkYMmw" + "Xx9hpHoq4TnhhHyL9WzPzuKYD0Vx4gvacV/ckkppFScnQKJ2hF+99\nLax1DbU+UImSknfDDFPYbYld" + "b1CD2rpJG1i6X2fRQ6NuK+F7jE05mqcIyE+ZajK+\nIpx8XFmE+tI1EEWsn3CzxMLiTQfXyFt/drM9i" + "GYfcDjYY+q5vzGU3Kxj68gjc96A\nOra79DGOmwX+4zIwo5sSzI3noHnhWPLsaRtE5jWu21Qkb+1BvB" + "jPmbQfN274OQfy\n8/BNNR/NZM1mJm/8x4Mt+h5d946XlIo0AkyYZXY/UQKBgQDYI3G3BCwaYk6MDMe" + "T\nIamRZo25phPtr3if47dhT2xFWJplIt35sW+6KjD6c1Qpb2aGOUh7JPmb57H54OgJ\nmojkS5tv9Y" + "EQZFfgCCZoeuqBx+ArqtJdkXOiNEFS0dpt44I+eO3Do5pnwKRemH+Y\ncqJ/eMH96UMzYDO7WNsyOyo" + "5UQKBgQDbYU0KbGbTrMEV4T9Q41rZ2TnWzs5moqn2\nCRtB8LOdKAZUG7FRsw5KgC1CvFn3Xuk+qphY" + "GUQeJvv7FjxMRUv4BktNpXju6eUj\n3tWHzI2QOkHaeq/XibwbNomfkdyTjtLX2+v8DBHcZnCSlukxc" + "JISyPqZ6CnTjXGE\nEGB+itBI5QKBgQCA+gWttOusguVkZWvivL+3aH9CPXy+5WsR3o1boE13xDu+Bm" + "R3\n0A5gBTVc/t1GLJf9mMlL0vCwvD5UYoWU1YbC1OtYkCQIaBiYM8TXrCGseF2pMTJ/\na4CZVp10k" + "o3J7W2XYgpgKIzHRQnQ+SeLDT0y3BjHMB9N1SaJsah7/RphQQKBgQCr\nL+4yKAzFOJUjQbVqpT8Lp5" + "qeqJofNOdzef+vIOjHxafKkiF4I0UPlZ276cY6ZfGU\nWQKwHGcvMDSI5fz/d0OksySn3mvT4uhPaV8" + "urMv6s7sXhY0Zn/0NLy2NOwDolBar\nIo2vDKwTVEyb1u75CWKzDemfl66ryj++Uhk6JZAKkQKBgQCc" + "NYVe7m648DzD0nu9\n3lgetBTaAS1zZmMs8Cinj44v0ksfqxrRBzBZcO9kCQqiJZ7uCAaVYcQ+PwkY+" + "05C\n+w1+KvdGcKM+8TQYTQM3s2B9IyKExRS/dbQf9F7stJL+k5vbt6OUerwfmbNI9R3t\ngDZ4DEfo" + "pPivs9dnequ9wfaPOw==" + "-----END PRIVATE KEY-----"); + + const auto authenticatorData = + browserMessageBuilder()->getArrayFromBase64("dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvAFAAAAAA"); + const auto clientData = browserMessageBuilder()->getArrayFromBase64( + "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiOXozNnZUZlFUTDk1TGY3V25aZ3l0ZTdvaEdlRi1YUmlMeGtMLUx1R1Uxem9wUm" + "1NSVVBMUxWd3pHcHlJbTFmT0JuMVFuUmEwUUgyN0FEQWFKR0h5c1EiLCJvcmlnaW4iOiJodHRwczovL3dlYmF1dGhuLmlvIiwiY3Jvc3NPcmln" + "aW4iOmZhbHNlfQ"); + + const auto signature = browserPasskeys()->buildSignature(authenticatorData, clientData, privateKeyPem); + QCOMPARE( + browserMessageBuilder()->getBase64FromArray(signature), + QString("MOGw6KrerCgPf2mPig7FOTFIUDXYAU1v2uZj89_NgQTg2UddWnAB3JId3pa4zXghj8CkjjadVOI_LvweJGCEpmPQnRby71yFXnja6j" + "Y3woX2b2klG2fB2alGZHHrVg6yVEmnAii4kYSdmoWxI7SmzLftoZfCJNFPFHujx2Pbr-6dIB02sZhtncetT0cpyWobtj9r7C5dIGfm" + "J5n-LccP-F9gXGqtbN605VrIkC2WNztjdk3dAt5FGM_dlIwSe-vP1dKfIuNqAEbgr2IVZAUFn_ZfzUo-XbXTysksuz9JZfEopJBiUi" + "9tjQDNvrYQFqB6wDPqkZAomkbRCohUb3TzCg")); +} + +void TestPasskeys::testCreatingAttestationObjectWithEC() +{ + // Predefined values for a desired outcome + const auto id = QString("yrzFJ5lwcpTwYMOdXSmxF5b5cYQlqBMzbbU_d-oFLO8"); + const auto predefinedFirst = QString("BuyvNFtikWFtGVkDplAqyjHElahp5fCH5dS4Ms0Ihd0"); + const auto predefinedSecond = QString("4u5_6Q8O6R0Hg0oDCdtCJLEL0yX_GDLhU5m3HUIE54M"); + + const auto publicKeyCredentialOptions = browserMessageBuilder()->getJsonObject(PublicKeyCredentialOptions.toUtf8()); + + auto rpIdHash = browserMessageBuilder()->getSha256HashAsBase64(QString("webauthn.io")); + QCOMPARE(rpIdHash, QString("dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvA")); + + TestingVariables testingVariables = {id, predefinedFirst, predefinedSecond}; + auto result = browserPasskeys()->buildAttestationObject(publicKeyCredentialOptions, "", id, testingVariables); + QCOMPARE( + QString(result.cborEncoded), + QString("\xA3" + "cfmtdnonegattStmt\xA0hauthDataX\xA4t\xA6\xEA\x92\x13\xC9\x9C/t\xB2$\x92\xB3 \xCF@&*\x94\xC1\xA9P\xA0" + "9\x7F)%\x0B`\x84\x1E\xF0" + "E\x00\x00\x00\x01\x01\x02\x03\x04\x05\x06\x07\b\x01\x02\x03\x04\x05\x06\x07\b\x00 \x8B\xB0\xCA" + "6\x17\xD6\xDE\x01\x11|\xEA\x94\r\xA0R\xC0\x80_\xF3r\xFBr\xB5\x02\x03:" + "\xBAr\x0Fi\x81\xFE\xA5\x01\x02\x03& \x01!X " + "e\xE2\xF2\x1F:cq\xD3G\xEA\xE0\xF7\x1F\xCF\xFA\\\xABO\xF6\x86\x88\x80\t\xAE\x81\x8BT\xB2\x9B\x15\x85~" + "\"X \\\x8E\x1E@\xDB\x97T-\xF8\x9B\xB0\xAD" + "5\xDC\x12^\xC3\x95\x05\xC6\xDF^\x03\xCB\xB4Q\x91\xFF|\xDB\x94\xB7")); + + // Double check that the result can be decoded + BrowserCbor browserCbor; + auto attestationJsonObject = browserCbor.getJsonFromCborData(result.cborEncoded); + + // Parse authData + auto authDataJsonObject = attestationJsonObject["authData"].toString(); + auto authDataArray = browserMessageBuilder()->getArrayFromBase64(authDataJsonObject); + QVERIFY(authDataArray.size() >= 37); + + auto authData = browserPasskeys()->parseAuthData(authDataArray); + auto credentialData = authData["credentialData"].toObject(); + auto flags = authData["flags"].toObject(); + auto publicKey = credentialData["publicKey"].toObject(); + + // The attestationObject should include the same ID after decoding with the response root + QCOMPARE(credentialData["credentialId"].toString(), QString("yrzFJ5lwcpTwYMOdXSmxF5b5cYQlqBMzbbU_d-oFLO8")); + QCOMPARE(authData["rpIdHash"].toString(), QString("dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvA")); + QCOMPARE(flags["AT"], true); + QCOMPARE(flags["UP"], true); + QCOMPARE(publicKey["1"], WebAuthnCoseKeyType::EC2); + QCOMPARE(publicKey["3"], WebAuthnAlgorithms::ES256); + QCOMPARE(publicKey["-1"], 1); + QCOMPARE(publicKey["-2"], predefinedFirst); + QCOMPARE(publicKey["-3"], predefinedSecond); +} + +void TestPasskeys::testCreatingAttestationObjectWithRSA() +{ + // Predefined values for a desired outcome + const auto id = QString("yrzFJ5lwcpTwYMOdXSmxF5b5cYQlqBMzbbU_d-oFLO8"); + const auto predefinedModulus = QString("vUhOZnyn8yn7U-nuHlsXZ6WDWLuYvevWWnwtoHxDEQq27vlp7yAfeVvAPkcvhxRcwoCEUespoa5" + "5IDbkpp2Ypd6b15KbB4C-_4gM4r2FK9gfXghLPAXsMhstYv4keNFb4ghdlY5oUU3JCqUSMyOpmd" + "HeX-RikLL0wgGv_tLT2DaDiWeyQCAtiDblr6COuTAU2kTpLc3Bn35geV9Iqw4iT8DwBQ-f8vjnI" + "EDANXKUiRPojfy1q7WwEl-zMv6Ke2jFHxf68u82BSy3u9DOQaa24FAHoCm8Yd0n5IazMyoxyttl" + "tRt8un8myVOGxcXMiR9_kQb9pu1RRLQMQLd-icE1Qw"); + const auto predefinedExponent = QString("AQAB"); + + // Force algorithm to RSA + QJsonArray pubKeyCredParams; + pubKeyCredParams.append(QJsonObject({{"type", "public-key"}, {"alg", -257}})); + + auto publicKeyCredentialOptions = browserMessageBuilder()->getJsonObject(PublicKeyCredentialOptions.toUtf8()); + publicKeyCredentialOptions["pubKeyCredParams"] = pubKeyCredParams; + + auto rpIdHash = browserMessageBuilder()->getSha256HashAsBase64(QString("webauthn.io")); + QCOMPARE(rpIdHash, QString("dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvA")); + + TestingVariables testingVariables = {id, predefinedModulus, predefinedExponent}; + auto result = browserPasskeys()->buildAttestationObject(publicKeyCredentialOptions, "", id, testingVariables); + + // Double check that the result can be decoded + BrowserCbor browserCbor; + auto attestationJsonObject = browserCbor.getJsonFromCborData(result.cborEncoded); + + // Parse authData + auto authDataJsonObject = attestationJsonObject["authData"].toString(); + auto authDataArray = browserMessageBuilder()->getArrayFromBase64(authDataJsonObject); + QVERIFY(authDataArray.size() >= 37); + + auto authData = browserPasskeys()->parseAuthData(authDataArray); + auto credentialData = authData["credentialData"].toObject(); + auto flags = authData["flags"].toObject(); + auto publicKey = credentialData["publicKey"].toObject(); + + // The attestationObject should include the same ID after decoding with the response root + QCOMPARE(credentialData["credentialId"].toString(), QString("yrzFJ5lwcpTwYMOdXSmxF5b5cYQlqBMzbbU_d-oFLO8")); + QCOMPARE(authData["rpIdHash"].toString(), QString("dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvA")); + QCOMPARE(flags["AT"], true); + QCOMPARE(flags["UP"], true); + QCOMPARE(publicKey["1"], WebAuthnCoseKeyType::RSA); + QCOMPARE(publicKey["3"], WebAuthnAlgorithms::RS256); + QCOMPARE(publicKey["-1"], predefinedModulus); + QCOMPARE(publicKey["-2"], predefinedExponent); +} + +void TestPasskeys::testRegister() +{ + // Predefined values for a desired outcome + const auto predefinedId = QString("yrzFJ5lwcpTwYMOdXSmxF5b5cYQlqBMzbbU_d-oFLO8"); + const auto predefinedX = QString("BuyvNFtikWFtGVkDplAqyjHElahp5fCH5dS4Ms0Ihd0"); + const auto predefinedY = QString("4u5_6Q8O6R0Hg0oDCdtCJLEL0yX_GDLhU5m3HUIE54M"); + const auto origin = QString("https://webauthn.io"); + const auto testDataPublicKey = browserMessageBuilder()->getJsonObject(PublicKeyCredential.toUtf8()); + const auto testDataResponse = testDataPublicKey["response"]; + const auto publicKeyCredentialOptions = browserMessageBuilder()->getJsonObject(PublicKeyCredentialOptions.toUtf8()); + + TestingVariables testingVariables = {predefinedId, predefinedX, predefinedY}; + auto result = + browserPasskeys()->buildRegisterPublicKeyCredential(publicKeyCredentialOptions, origin, testingVariables); + auto publicKeyCredential = result.response; + QCOMPARE(publicKeyCredential["type"], QString("public-key")); + QCOMPARE(publicKeyCredential["authenticatorAttachment"], QString("platform")); + QCOMPARE(publicKeyCredential["id"], QString("yrzFJ5lwcpTwYMOdXSmxF5b5cYQlqBMzbbU_d-oFLO8")); + + auto response = publicKeyCredential["response"].toObject(); + auto attestationObject = response["attestationObject"].toString(); + auto clientDataJson = response["clientDataJSON"].toString(); + QCOMPARE(attestationObject, testDataResponse["attestationObject"].toString()); + + // Parse clientDataJSON + auto clientDataByteArray = browserMessageBuilder()->getArrayFromBase64(clientDataJson); + auto clientDataJsonObject = browserMessageBuilder()->getJsonObject(clientDataByteArray); + QCOMPARE(clientDataJsonObject["challenge"], + QString("lVeHzVxWsr8MQxMkZF0ti6FXhdgMljqKzgA-q_zk2Mnii3eJ47VF97sqUoYktVC85WAZ1uIASm-a_lDFZwsLfw")); + QCOMPARE(clientDataJsonObject["origin"], origin); + QCOMPARE(clientDataJsonObject["type"], QString("webauthn.create")); +} + +void TestPasskeys::testGet() +{ + const auto privateKeyPem = QString("-----BEGIN PRIVATE KEY-----" + "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg5DX2R6I37nMSZqCp" + "XfHlE3UeitkGGE03FqGsdfxIBoOhRANCAAQG7K80W2KRYW0ZWQOmUCrKMcSVqGnl" + "8Ifl1LgyzQiF3eLuf+kPDukdB4NKAwnbQiSxC9Ml/xgy4VOZtx1CBOeD" + "-----END PRIVATE KEY-----"); + const auto origin = QString("https://webauthn.io"); + const auto id = QString("yrzFJ5lwcpTwYMOdXSmxF5b5cYQlqBMzbbU_d-oFLO8"); + const auto publicKeyCredentialRequestOptions = + browserMessageBuilder()->getJsonObject(PublicKeyCredentialRequestOptions.toUtf8()); + + auto publicKeyCredential = browserPasskeys()->buildGetPublicKeyCredential( + publicKeyCredentialRequestOptions, origin, id, {}, privateKeyPem); + QVERIFY(!publicKeyCredential.isEmpty()); + QCOMPARE(publicKeyCredential["id"].toString(), id); + + auto response = publicKeyCredential["response"].toObject(); + QCOMPARE(response["authenticatorData"].toString(), QString("dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvAFAAAAAA")); + QCOMPARE(response["clientDataJSON"].toString(), + QString("eyJjaGFsbGVuZ2UiOiI5ejM2dlRmUVRMOTVMZjdXblpneXRlN29oR2VGLVhSaUx4a0wtTHVHVTF6b3BSbU1JVUExTFZ3ekdwe" + "UltMWZPQm4xUW5SYTBRSDI3QURBYUpHSHlzUSIsImNyb3NzT3JpZ2luIjpmYWxzZSwib3JpZ2luIjoiaHR0cHM6Ly93ZWJhdX" + "Robi5pbyIsInR5cGUiOiJ3ZWJhdXRobi5nZXQifQ")); + QCOMPARE( + response["signature"].toString(), + QString("MEUCIHFv0lOOGGloi_XoH5s3QDSs__8yAp9ZTMEjNiacMpOxAiEA04LAfO6TE7j12XNxd3zHQpn4kZN82jQFPntPiPBSD5c")); + + auto clientDataJson = response["clientDataJSON"].toString(); + auto clientDataByteArray = browserMessageBuilder()->getArrayFromBase64(clientDataJson); + auto clientDataJsonObject = browserMessageBuilder()->getJsonObject(clientDataByteArray); + QCOMPARE(clientDataJsonObject["challenge"].toString(), publicKeyCredentialRequestOptions["challenge"].toString()); +} + +void TestPasskeys::testExtensions() +{ + auto extensions = QJsonObject({{"credProps", true}, {"uvm", true}}); + auto result = browserPasskeys()->buildExtensionData(extensions); + + BrowserCbor cbor; + auto extensionJson = cbor.getJsonFromCborData(result); + auto uvmArray = extensionJson["uvm"].toArray(); + QCOMPARE(extensionJson["credProps"].toObject()["rk"].toBool(), true); + QCOMPARE(uvmArray.size(), 1); + QCOMPARE(uvmArray.first().toArray().size(), 3); + + auto partial = QJsonObject({{"props", true}, {"uvm", true}}); + auto faulty = QJsonObject({{"uvx", true}}); + auto partialData = browserPasskeys()->buildExtensionData(partial); + auto faultyData = browserPasskeys()->buildExtensionData(faulty); + + auto partialJson = cbor.getJsonFromCborData(partialData); + QCOMPARE(partialJson["uvm"].toArray().size(), 1); + + auto faultyJson = cbor.getJsonFromCborData(faultyData); + QCOMPARE(faultyJson.size(), 0); +} + +void TestPasskeys::testParseFlags() +{ + auto registerResult = browserPasskeys()->parseFlags("\x45"); + QCOMPARE(registerResult["ED"], false); + QCOMPARE(registerResult["AT"], true); + QCOMPARE(registerResult["BS"], false); + QCOMPARE(registerResult["BE"], false); + QCOMPARE(registerResult["UV"], true); + QCOMPARE(registerResult["UP"], true); + + auto getResult = browserPasskeys()->parseFlags("\x05"); // Only UP and UV + QCOMPARE(getResult["ED"], false); + QCOMPARE(getResult["AT"], false); + QCOMPARE(getResult["BS"], false); + QCOMPARE(getResult["BE"], false); + QCOMPARE(getResult["UV"], true); + QCOMPARE(getResult["UP"], true); +} + +void TestPasskeys::testSetFlags() +{ + auto registerJson = + QJsonObject({{"ED", false}, {"AT", true}, {"BS", false}, {"BE", false}, {"UV", true}, {"UP", true}}); + auto registerResult = browserPasskeys()->setFlagsFromJson(registerJson); + QCOMPARE(registerResult, 0x45); + + auto getJson = + QJsonObject({{"ED", false}, {"AT", false}, {"BS", false}, {"BE", false}, {"UV", true}, {"UP", true}}); + auto getResult = browserPasskeys()->setFlagsFromJson(getJson); + QCOMPARE(getResult, 0x05); + + // With "discouraged", so UV is false + auto discouragedJson = + QJsonObject({{"ED", false}, {"AT", false}, {"BS", false}, {"BE", false}, {"UV", false}, {"UP", true}}); + auto discouragedResult = browserPasskeys()->setFlagsFromJson(discouragedJson); + QCOMPARE(discouragedResult, 0x01); +} diff --git a/tests/TestPasskeys.h b/tests/TestPasskeys.h new file mode 100644 index 0000000000..ef2b68c24c --- /dev/null +++ b/tests/TestPasskeys.h @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2023 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEEPASSXC_TESTPASSKEYS_H +#define KEEPASSXC_TESTPASSKEYS_H + +#include + +#include "browser/BrowserPasskeys.h" + +class TestPasskeys : public QObject +{ + Q_OBJECT + +private slots: + void initTestCase(); + void init(); + + void testBase64WithHexStrings(); + void testDecodeResponseData(); + + void testLoadingECPrivateKeyFromPem(); + void testLoadingRSAPrivateKeyFromPem(); + void testCreatingAttestationObjectWithEC(); + void testCreatingAttestationObjectWithRSA(); + void testRegister(); + void testGet(); + + void testExtensions(); + void testParseFlags(); + void testSetFlags(); +}; +#endif // KEEPASSXC_TESTPASSKEYS_H