diff --git a/CMakeLists.txt b/CMakeLists.txt index e7183f1691..640108cbff 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -446,6 +446,7 @@ elseif(APPLE AND WITH_APP_BUNDLE) set(PROXY_INSTALL_DIR "${BUNDLE_INSTALL_DIR}/MacOS") set(BIN_INSTALL_DIR "${BUNDLE_INSTALL_DIR}/MacOS") set(PLUGIN_INSTALL_DIR "${BUNDLE_INSTALL_DIR}/PlugIns") + set(XPC_INSTALL_DIR "${BUNDLE_INSTALL_DIR}/XPCServices") set(DATA_INSTALL_DIR "${BUNDLE_INSTALL_DIR}/Resources") else() include(GNUInstallDirs) diff --git a/share/macosx/keepassxc.entitlements b/share/macosx/keepassxc.entitlements index 7126b7ac5b..37efc7a585 100644 --- a/share/macosx/keepassxc.entitlements +++ b/share/macosx/keepassxc.entitlements @@ -8,5 +8,7 @@ G2S7P7J672.org.keepassxc.keepassxc + com.apple.developer.authentication-services.autofill-credential-provider + diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 3aa82dc305..0e36ad8675 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -227,10 +227,15 @@ if(APPLE) gui/osutils/macutils/ScreenLockListenerMac.cpp gui/osutils/macutils/AppKitImpl.mm gui/osutils/macutils/AppKit.h - quickunlock/TouchID.mm) + quickunlock/TouchID.mm + autofill/AutoFill.h + autofill/AutoFill.mm + autofill/AutoFillProviderProtocol.h + autofill/AutoFillXPCProtocol.h) # TODO: Remove -Wno-error once deprecation warnings have been resolved. set_source_files_properties(quickunlock/TouchID.mm PROPERTY COMPILE_FLAGS "-Wno-old-style-cast") + set_source_files_properties(autofill/AutoFill.mm PROPERTY COMPILE_FLAGS "-fobjc-arc") endif() if(UNIX AND NOT APPLE) @@ -398,7 +403,7 @@ target_link_libraries(keepassxc_gui ${sshagent_LIB}) if(APPLE) - target_link_libraries(keepassxc_gui "-framework Foundation -framework AppKit -framework Carbon -framework Security -framework LocalAuthentication -framework ScreenCaptureKit") + target_link_libraries(keepassxc_gui "-framework Foundation -framework AppKit -framework Carbon -framework Security -framework LocalAuthentication -framework ScreenCaptureKit -framework AuthenticationServices") if(Qt5MacExtras_FOUND) target_link_libraries(keepassxc_gui Qt5::MacExtras) endif() @@ -591,3 +596,132 @@ endif() # The install commands in this subdirectory will be executed after all the install commands in the # current scope are ran. It is required for correct functioning of macdeployqt. add_subdirectory(post_install) + +if(APPLE AND WITH_APP_BUNDLE) + set(AUTOFILL_TARGET "keepassxc-autofill") + set(AUTOFILL_XPC_TARGET "keepassxc-autofill-xpc") + set(MACOSX_EXTENSION_BUNDLE_ID "org.keepassxc.keepassxc.autofill") + set(MACOSX_XPC_BUNDLE_ID "org.keepassxc.KeePassXC.AutoFill-XPC-Service") + + # Configure extension Info.plist + configure_file( + "${CMAKE_CURRENT_SOURCE_DIR}/autofill/mac/Info.plist.in" + "${CMAKE_CURRENT_BINARY_DIR}/autofill/mac/Info.plist" + @ONLY + ) + + # Configure extension entitlements + configure_file( + "${CMAKE_CURRENT_SOURCE_DIR}/autofill/mac/MacAutoFill.entitlements" + "${CMAKE_CURRENT_BINARY_DIR}/autofill/mac/MacAutoFill.entitlements" + @ONLY + ) + + # Configure XPC service Info.plist + configure_file( + "${CMAKE_CURRENT_SOURCE_DIR}/autofill/AutoFillXPCService-Info.plist" + "${CMAKE_CURRENT_BINARY_DIR}/autofill/AutoFillXPCService-Info.plist" + @ONLY + ) + + # =================== + # AutoFill Extension + # =================== + add_library(${AUTOFILL_TARGET} MODULE + autofill/AutoFillProviderProtocol.h + autofill/AutoFillXPCProtocol.h + autofill/CredentialProviderViewController.mm + ) + + # Disable Qt AUTOMOC for pure Objective-C++ extension + set_target_properties(${AUTOFILL_TARGET} PROPERTIES + AUTOMOC OFF + AUTOUIC OFF + AUTORCC OFF + ) + + set_target_properties(${AUTOFILL_TARGET} PROPERTIES + BUNDLE TRUE + BUNDLE_EXTENSION "appex" + MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_BINARY_DIR}/autofill/mac/Info.plist" + MACOSX_BUNDLE_GUI_IDENTIFIER "${MACOSX_EXTENSION_BUNDLE_ID}" + MACOSX_BUNDLE_BUNDLE_NAME "KeePassXC AutoFill" + XCODE_ATTRIBUTE_CODE_SIGN_ENTITLEMENTS "${CMAKE_CURRENT_BINARY_DIR}/autofill/mac/MacAutoFill.entitlements" + XCODE_ATTRIBUTE_SKIP_INSTALL "YES" + XCODE_ATTRIBUTE_PRODUCT_BUNDLE_IDENTIFIER "${MACOSX_EXTENSION_BUNDLE_ID}" + XCODE_ATTRIBUTE_CLANG_ENABLE_OBJC_ARC "YES" + XCODE_ATTRIBUTE_WRAPPER_EXTENSION "appex" + ) + + target_compile_options(${AUTOFILL_TARGET} PRIVATE "-fobjc-arc") + + target_link_libraries(${AUTOFILL_TARGET} + "-framework AuthenticationServices" + "-framework AppKit" + "-framework Foundation" + ) + + # =================== + # XPC Service + # =================== + add_executable(${AUTOFILL_XPC_TARGET} + autofill/AutoFillXPCService.m + autofill/AutoFillProviderProtocol.h + autofill/AutoFillXPCProtocol.h + ) + + # Disable Qt AUTOMOC for pure Objective-C XPC service + set_target_properties(${AUTOFILL_XPC_TARGET} PROPERTIES + AUTOMOC OFF + AUTOUIC OFF + AUTORCC OFF + ) + + set_target_properties(${AUTOFILL_XPC_TARGET} PROPERTIES + MACOSX_BUNDLE TRUE + BUNDLE_EXTENSION "xpc" + MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_BINARY_DIR}/autofill/AutoFillXPCService-Info.plist" + MACOSX_BUNDLE_GUI_IDENTIFIER "${MACOSX_XPC_BUNDLE_ID}" + MACOSX_BUNDLE_BUNDLE_NAME "KeePassXC AutoFill XPC Service" + XCODE_ATTRIBUTE_SKIP_INSTALL "YES" + XCODE_ATTRIBUTE_PRODUCT_BUNDLE_IDENTIFIER "${MACOSX_XPC_BUNDLE_ID}" + XCODE_ATTRIBUTE_CLANG_ENABLE_OBJC_ARC "YES" + ) + + target_compile_options(${AUTOFILL_XPC_TARGET} PRIVATE "-fobjc-arc") + + target_link_libraries(${AUTOFILL_XPC_TARGET} + "-framework Foundation" + ) + + # =================== + # Embed into main app + # =================== + add_dependencies(${PROGNAME} ${AUTOFILL_TARGET} ${AUTOFILL_XPC_TARGET}) + + # Embed extension via Xcode's native embedding (CMake 3.21+) + if(${CMAKE_VERSION} VERSION_GREATER_EQUAL "3.21.0") + set_target_properties(${PROGNAME} PROPERTIES + XCODE_EMBED_APP_EXTENSIONS ${AUTOFILL_TARGET} + ) + else() + # Fallback: copy extension into PlugIns directory manually + add_custom_command(TARGET ${PROGNAME} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E make_directory "$/PlugIns" + COMMAND ${CMAKE_COMMAND} -E copy_directory + "$" + "$/PlugIns/${AUTOFILL_TARGET}.appex" + COMMENT "Embedding AutoFill extension into app bundle" + ) + endif() + + # Copy XPC service into XPCServices directory + add_custom_command(TARGET ${PROGNAME} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E make_directory "$/XPCServices" + COMMAND ${CMAKE_COMMAND} -E copy_directory + "$" + "$/XPCServices/${AUTOFILL_XPC_TARGET}.xpc" + COMMENT "Embedding AutoFill XPC service into app bundle" + ) + +endif() diff --git a/src/autofill/AutoFill.h b/src/autofill/AutoFill.h new file mode 100644 index 0000000000..ccf665211e --- /dev/null +++ b/src/autofill/AutoFill.h @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2024 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_AUTOFILL_H +#define KEEPASSX_AUTOFILL_H + +#include + +class AutoFillPrivate; + +class AutoFill : public QObject +{ + Q_OBJECT + +public: + explicit AutoFill(QObject* parent = nullptr); + ~AutoFill(); + + bool isAvailable() const; + +public Q_SLOTS: + void start(); + void stop(); + +private: + Q_DISABLE_COPY(AutoFill) + + AutoFillPrivate* d_ptr; +}; + +#endif // KEEPASSX_AUTOFILL_H diff --git a/src/autofill/AutoFill.mm b/src/autofill/AutoFill.mm new file mode 100644 index 0000000000..7d89bcb550 --- /dev/null +++ b/src/autofill/AutoFill.mm @@ -0,0 +1,759 @@ +/* + * Copyright (C) 2024 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 "AutoFill.h" +#include "AutoFillUtils.h" + +#include "core/Database.h" +#include "core/Entry.h" +#include "core/Group.h" +#include "core/Metadata.h" +#include "core/Tools.h" +#include "gui/DatabaseWidget.h" +#include "gui/MainWindow.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef Q_OS_MACOS + +#import +#import +#import + +#import "AutoFillProviderProtocol.h" +#import "AutoFillXPCProtocol.h" + +Q_LOGGING_CATEGORY(lcAutoFill, "keepassxc.autofill") + +@class AutoFillHostAdapter; + +class AutoFillPrivate : public QObject +{ +public: + struct CredentialRecord + { + QString recordIdentifier; + QString domain; + QString username; + QString password; + QString title; + QString url; + QString otp; + + bool isValid() const + { + return !recordIdentifier.isEmpty() && !domain.isEmpty() && !password.isEmpty(); + } + }; + + explicit AutoFillPrivate(AutoFill* parent); + ~AutoFillPrivate() override; + + bool isAvailable() const + { + return m_available; + } + + void start(); + void stop(); + + void fetchCredentialsMatchingDomain(const QString& domain, void (^reply)(NSArray*>*)); + void fetchCredentialWithRecordIdentifier(const QString& recordId, void (^reply)(NSDictionary*)); + +private: + void connectSignals(); + void watchExistingDatabases(); + void watchDatabase(DatabaseWidget* widget); + void scheduleIdentityRefresh(); + void refreshIdentityStore(); + void clearIdentityStore(); + void ensureListener(); + void connectToServiceIfNeeded(); + void handleServiceInvalidation(); + + QVector collectCredentialsForDomain(const QString& domain) const; + QVector collectAllCredentialRecords() const; + CredentialRecord buildRecord(const QSharedPointer& database, + Entry* entry, + const QString& domain, + const QString& sourceUrl) const; + QStringList entryDomains(Entry* entry) const; + QString recordIdentifierFor(const QSharedPointer& database, Entry* entry) const; + Entry* entryForRecordIdentifier(const QString& recordId, QSharedPointer& database) const; + DatabaseWidget* databaseWidgetForUuid(const QUuid& uuid) const; + NSArray*>* serializeCredentialList(const QVector& records) const; + NSDictionary* serializeCredential(const CredentialRecord& record) const; + + bool m_available{false}; + bool m_running{false}; + bool m_signalsConnected{false}; + bool m_serviceRegistered{false}; + QSet m_watchedDatabases; + QTimer* m_identityRefreshTimer{nullptr}; + NSXPCConnection* m_serviceConnection{nil}; + NSXPCListener* m_listener{nil}; + AutoFillHostAdapter* m_hostAdapter{nil}; +}; + +@interface AutoFillHostAdapter : NSObject +@property(nonatomic, assign) AutoFillPrivate* owner; +@end + +@implementation AutoFillHostAdapter + +- (BOOL)listener:(NSXPCListener*)listener shouldAcceptNewConnection:(NSXPCConnection*)connection +{ + Q_UNUSED(listener); + connection.exportedInterface = [NSXPCInterface interfaceWithProtocol:@protocol(AutoFillProviderProtocol)]; + connection.exportedObject = self; + [connection resume]; + return YES; +} + +- (void)fetchCredentialsMatchingDomain:(NSString*)domain + withReply:(void (^)(NSArray*>*))reply +{ + if (!reply) { + return; + } + void (^replyCopy)(NSArray*>*) = [reply copy]; + QString host = QString::fromNSString(domain); + AutoFillPrivate* owner = self.owner; + if (!owner) { + replyCopy(@[]); + return; + } + owner->fetchCredentialsMatchingDomain(host, replyCopy); +} + +- (void)fetchCredentialWithRecordIdentifier:(NSString*)recordIdentifier + withReply:(void (^)(NSDictionary*))reply +{ + if (!reply) { + return; + } + void (^replyCopy)(NSDictionary*) = [reply copy]; + QString identifier = QString::fromNSString(recordIdentifier); + AutoFillPrivate* owner = self.owner; + if (!owner) { + replyCopy(@{}); + return; + } + owner->fetchCredentialWithRecordIdentifier(identifier, replyCopy); +} + +@end + +AutoFillPrivate::AutoFillPrivate(AutoFill* parent) + : QObject(parent) +{ + if (@available(macOS 12.0, *)) { + m_available = true; + } + + m_identityRefreshTimer = new QTimer(this); + m_identityRefreshTimer->setSingleShot(true); + m_identityRefreshTimer->setInterval(300); + connect(m_identityRefreshTimer, &QTimer::timeout, this, [this]() { refreshIdentityStore(); }); +} + +AutoFillPrivate::~AutoFillPrivate() +{ + stop(); +} + +void AutoFillPrivate::start() +{ + qCDebug(lcAutoFill) << "Starting AutoFill service."; + if (!m_available || m_running) { + return; + } + + m_running = true; + connectSignals(); + watchExistingDatabases(); + ensureListener(); + connectToServiceIfNeeded(); + scheduleIdentityRefresh(); +} + +void AutoFillPrivate::stop() +{ + qCDebug(lcAutoFill) << "Stopping AutoFill service."; + if (!m_running) { + return; + } + + m_running = false; + m_watchedDatabases.clear(); + m_serviceRegistered = false; + + if (m_identityRefreshTimer) { + m_identityRefreshTimer->stop(); + } + + if (m_serviceConnection) { + [m_serviceConnection invalidate]; + m_serviceConnection.invalidationHandler = nil; + m_serviceConnection = nil; + } + + if (m_listener) { + [m_listener invalidate]; + m_listener = nil; + } + + if (m_hostAdapter) { + m_hostAdapter.owner = nullptr; + } + m_hostAdapter = nil; + clearIdentityStore(); +} + +void AutoFillPrivate::fetchCredentialsMatchingDomain(const QString& domain, + void (^reply)(NSArray*>*)) +{ + if (!reply) { + return; + } + + void (^replyCopy)(NSArray*>*) = [reply copy]; + QPointer guard(this); + QMetaObject::invokeMethod(this, + [guard, replyCopy, domain]() { + if (!guard) { + replyCopy(@[]); + return; + } + auto matches = guard->collectCredentialsForDomain(domain); + replyCopy(guard->serializeCredentialList(matches)); + }, + Qt::QueuedConnection); +} + +void AutoFillPrivate::fetchCredentialWithRecordIdentifier(const QString& recordId, + void (^reply)(NSDictionary*)) +{ + if (!reply) { + return; + } + + void (^replyCopy)(NSDictionary*) = [reply copy]; + QPointer guard(this); + QMetaObject::invokeMethod(this, + [guard, replyCopy, recordId]() { + if (!guard) { + replyCopy(@{}); + return; + } + QSharedPointer database; + auto* entry = guard->entryForRecordIdentifier(recordId, database); + if (!entry || database.isNull()) { + replyCopy(@{}); + return; + } + auto domains = guard->entryDomains(entry); + if (domains.isEmpty()) { + replyCopy(@{}); + return; + } + auto record = guard->buildRecord(database, entry, domains.first(), entry->displayUrl()); + replyCopy(guard->serializeCredential(record)); + }, + Qt::QueuedConnection); +} + +void AutoFillPrivate::connectSignals() +{ + if (m_signalsConnected) { + return; + } + + if (auto* window = getMainWindow()) { + connect(window, &MainWindow::databaseUnlocked, this, [this](DatabaseWidget* widget) { + watchDatabase(widget); + scheduleIdentityRefresh(); + }); + connect(window, &MainWindow::databaseLocked, this, [this](DatabaseWidget* widget) { + m_watchedDatabases.remove(widget); + scheduleIdentityRefresh(); + }); + connect(window, &MainWindow::activeDatabaseChanged, this, [this](DatabaseWidget*) { + scheduleIdentityRefresh(); + }); + } + + m_signalsConnected = true; +} + +void AutoFillPrivate::watchExistingDatabases() +{ + if (auto* window = getMainWindow()) { + for (auto* widget : window->getOpenDatabases()) { + watchDatabase(widget); + } + } +} + +void AutoFillPrivate::watchDatabase(DatabaseWidget* widget) +{ + if (!widget || m_watchedDatabases.contains(widget)) { + return; + } + + m_watchedDatabases.insert(widget); + + connect(widget, &DatabaseWidget::databaseModified, this, [this]() { scheduleIdentityRefresh(); }); + connect(widget, &DatabaseWidget::databaseSaved, this, [this]() { scheduleIdentityRefresh(); }); + connect(widget, &DatabaseWidget::databaseReplaced, this, [this](const QSharedPointer&, const QSharedPointer&) { + scheduleIdentityRefresh(); + }); + connect(widget, &DatabaseWidget::databaseLocked, this, [this, widget]() { + m_watchedDatabases.remove(widget); + scheduleIdentityRefresh(); + }); + connect(widget, &QObject::destroyed, this, [this, widget]() { + m_watchedDatabases.remove(widget); + scheduleIdentityRefresh(); + }); +} + +void AutoFillPrivate::scheduleIdentityRefresh() +{ + if (!m_available || !m_running || !m_identityRefreshTimer) { + return; + } + + if (!m_identityRefreshTimer->isActive()) { + m_identityRefreshTimer->start(); + } +} + +void AutoFillPrivate::refreshIdentityStore() +{ + if (!m_available) { + return; + } + + qCDebug(lcAutoFill) << "Refreshing identity store."; + auto records = collectAllCredentialRecords(); + qCDebug(lcAutoFill) << "Found" << records.size() << "records to refresh."; + + if (@available(macOS 12.0, *)) { + auto identities = [NSMutableArray arrayWithCapacity:records.size()]; + for (const auto& record : records) { + auto* serviceIdentifier = [[ASCredentialServiceIdentifier alloc] + initWithIdentifier:record.domain.toNSString() + type:ASCredentialServiceIdentifierTypeDomain]; + auto* identity = [[ASPasswordCredentialIdentity alloc] + initWithServiceIdentifier:serviceIdentifier + user:record.username.toNSString() + recordIdentifier:record.recordIdentifier.toNSString()]; + [identities addObject:identity]; + } + + dispatch_async(dispatch_get_main_queue(), ^{ + qCDebug(lcAutoFill) << "Calling replaceCredentialIdentitiesWithIdentities..."; + ASCredentialIdentityStore* store = [ASCredentialIdentityStore sharedStore]; + if (records.isEmpty()) { + [store removeAllCredentialIdentitiesWithCompletion:nil]; + qCDebug(lcAutoFill) << "Cleared all identities."; + return; + } + + [store replaceCredentialIdentitiesWithIdentities:identities + completion:^(BOOL success, NSError* error) { + if (success) { + qCDebug(lcAutoFill) << "Successfully refreshed identities."; + } else if (error) { + qCWarning(lcAutoFill) + << "Unable to refresh AutoFill identities:" + << QString::fromNSString(error.localizedDescription); + } + }]; + }); + } +} + +void AutoFillPrivate::clearIdentityStore() +{ + if (!m_available) { + return; + } + + if (@available(macOS 12.0, *)) { + dispatch_async(dispatch_get_main_queue(), ^{ + [[ASCredentialIdentityStore sharedStore] removeAllCredentialIdentitiesWithCompletion:nil]; + }); + } +} + +void AutoFillPrivate::ensureListener() +{ + if (m_listener) { + return; + } + + m_hostAdapter = [[AutoFillHostAdapter alloc] init]; + m_hostAdapter.owner = this; + m_listener = [NSXPCListener anonymousListener]; + m_listener.delegate = m_hostAdapter; + [m_listener resume]; +} + +void AutoFillPrivate::connectToServiceIfNeeded() +{ + if (m_serviceRegistered || !m_listener || !m_running) { + return; + } + + qCDebug(lcAutoFill) << "Connecting to AutoFill XPC service."; + m_serviceConnection = [[NSXPCConnection alloc] initWithServiceName:@"org.keepassxc.KeePassXC.AutoFill-XPC-Service"]; + m_serviceConnection.remoteObjectInterface = [NSXPCInterface interfaceWithProtocol:@protocol(AutoFillXPCProtocol)]; + + // Use QPointer to safely prevent use-after-free if AutoFillPrivate + // is destroyed while an invalidation callback is in-flight + QPointer guard(this); + m_serviceConnection.invalidationHandler = ^{ + dispatch_async(dispatch_get_main_queue(), ^{ + if (guard) { + guard->handleServiceInvalidation(); + } + }); + }; + + [m_serviceConnection resume]; + + id remote = + [m_serviceConnection remoteObjectProxyWithErrorHandler:^(NSError* error) { + qCWarning(lcAutoFill) << "AutoFill XPC service unavailable:" << QString::fromNSString(error.localizedDescription); + }]; + + [remote registerProvider:m_listener.endpoint + withReply:^(NSError* error) { + if (error) { + qCWarning(lcAutoFill) << "Failed to register AutoFill provider:" + << QString::fromNSString(error.localizedDescription); + return; + } + qCDebug(lcAutoFill) << "Successfully registered AutoFill provider."; + m_serviceRegistered = true; + }]; +} + +void AutoFillPrivate::handleServiceInvalidation() +{ + qCDebug(lcAutoFill) << "AutoFill service connection invalidated."; + if (!m_running) { + if (m_serviceConnection) { + m_serviceConnection.invalidationHandler = nil; + } + m_serviceConnection = nil; + m_serviceRegistered = false; + return; + } + + m_serviceRegistered = false; + if (m_serviceConnection) { + m_serviceConnection.invalidationHandler = nil; + } + m_serviceConnection = nil; + connectToServiceIfNeeded(); +} + +QVector AutoFillPrivate::collectCredentialsForDomain(const QString& domain) const +{ + QVector matches; + if (domain.isEmpty()) { + return collectAllCredentialRecords(); + } + + const auto normalizedDomain = AutoFillUtils::normalizeHost(domain); + if (normalizedDomain.isEmpty()) { + return matches; + } + + if (auto* window = getMainWindow()) { + for (auto* widget : window->getOpenDatabases()) { + if (!widget || widget->isLocked()) { + continue; + } + + auto database = widget->database(); + if (database.isNull()) { + continue; + } + + auto* root = database->rootGroup(); + if (!root) { + continue; + } + + for (auto* entry : root->entriesRecursive(false)) { + if (!entry || entry->isRecycled()) { + continue; + } + + const auto domains = entryDomains(entry); + auto matchIt = std::find_if(domains.begin(), domains.end(), [&](const QString& candidate) { + return AutoFillUtils::hostsMatch(normalizedDomain, candidate); + }); + if (matchIt == domains.end()) { + continue; + } + + auto record = buildRecord(database, entry, *matchIt, entry->displayUrl()); + if (record.isValid()) { + matches.append(record); + } + } + } + } + return matches; +} + +QVector AutoFillPrivate::collectAllCredentialRecords() const +{ + QVector records; + QSet dedup; + + if (auto* window = getMainWindow()) { + for (auto* widget : window->getOpenDatabases()) { + if (!widget || widget->isLocked()) { + continue; + } + auto database = widget->database(); + if (database.isNull()) { + continue; + } + auto* root = database->rootGroup(); + if (!root) { + continue; + } + + for (auto* entry : root->entriesRecursive(false)) { + if (!entry || entry->isRecycled()) { + continue; + } + + for (const auto& domain : entryDomains(entry)) { + auto record = buildRecord(database, entry, domain, entry->displayUrl()); + if (!record.isValid()) { + continue; + } + + const auto key = record.recordIdentifier + QLatin1Char('|') + record.domain; + if (dedup.contains(key)) { + continue; + } + dedup.insert(key); + records.append(record); + } + } + } + } + + return records; +} + +AutoFillPrivate::CredentialRecord AutoFillPrivate::buildRecord(const QSharedPointer& database, + Entry* entry, + const QString& domain, + const QString& sourceUrl) const +{ + CredentialRecord record; + if (database.isNull() || !entry) { + return record; + } + + record.recordIdentifier = recordIdentifierFor(database, entry); + record.domain = domain; + record.username = entry->resolveMultiplePlaceholders(entry->username()); + record.password = entry->resolveMultiplePlaceholders(entry->password()); + record.title = entry->resolveMultiplePlaceholders(entry->title()); + record.url = sourceUrl; + if (entry->hasTotp()) { + bool validTotp = false; + const auto totpValue = entry->totp(&validTotp); + if (validTotp) { + record.otp = totpValue; + } + } + return record; +} + +QStringList AutoFillPrivate::entryDomains(Entry* entry) const +{ + QStringList domains; + if (!entry) { + return domains; + } + + QSet uniqueHosts; + for (const auto& url : entry->getAllUrls()) { + const auto host = AutoFillUtils::hostFromUrl(url); + if (host.isEmpty() || uniqueHosts.contains(host)) { + continue; + } + uniqueHosts.insert(host); + domains.append(host); + } + + return domains; +} + +QString AutoFillPrivate::recordIdentifierFor(const QSharedPointer& database, Entry* entry) const +{ + if (database.isNull() || !entry) { + return {}; + } + + return database->uuid().toString(QUuid::WithoutBraces) + QLatin1Char(':') + entry->uuidToHex(); +} + +Entry* AutoFillPrivate::entryForRecordIdentifier(const QString& recordId, QSharedPointer& database) const +{ + database.clear(); + + const auto parts = recordId.split(QLatin1Char(':'), Qt::SkipEmptyParts); + if (parts.size() != 2) { + return nullptr; + } + + QUuid databaseUuid(QStringLiteral("{%1}").arg(parts.at(0))); + auto entryUuid = Tools::hexToUuid(parts.at(1)); + + if (databaseUuid.isNull() || entryUuid.isNull()) { + return nullptr; + } + + if (auto* widget = databaseWidgetForUuid(databaseUuid)) { + auto db = widget->database(); + if (!db.isNull() && db->rootGroup()) { + database = db; + return db->rootGroup()->findEntryByUuid(entryUuid); + } + } + + return nullptr; +} + +DatabaseWidget* AutoFillPrivate::databaseWidgetForUuid(const QUuid& uuid) const +{ + if (uuid.isNull()) { + return nullptr; + } + + if (auto* window = getMainWindow()) { + for (auto* widget : window->getOpenDatabases()) { + if (!widget || widget->isLocked()) { + continue; + } + auto database = widget->database(); + if (!database.isNull() && database->uuid() == uuid) { + return widget; + } + } + } + + return nullptr; +} + +NSArray*>* AutoFillPrivate::serializeCredentialList( + const QVector& records) const +{ + auto* list = [NSMutableArray arrayWithCapacity:records.size()]; + for (const auto& record : records) { + auto* dictionary = serializeCredential(record); + if (dictionary) { + [list addObject:dictionary]; + } + } + return list; +} + +NSDictionary* AutoFillPrivate::serializeCredential(const CredentialRecord& record) const +{ + if (!record.isValid()) { + return nil; + } + + NSMutableDictionary* dict = [NSMutableDictionary dictionary]; + dict[AutoFillCredentialRecordIdentifierKey] = record.recordIdentifier.toNSString(); + dict[AutoFillCredentialDomainKey] = record.domain.toNSString(); + dict[AutoFillCredentialUsernameKey] = record.username.toNSString(); + dict[AutoFillCredentialPasswordKey] = record.password.toNSString(); + dict[AutoFillCredentialTitleKey] = record.title.toNSString(); + dict[AutoFillCredentialUrlKey] = record.url.toNSString(); + if (!record.otp.isEmpty()) { + dict[AutoFillCredentialOtpKey] = record.otp.toNSString(); + } + return dict; +} + +#else + +class AutoFillPrivate +{ +public: + explicit AutoFillPrivate(AutoFill*) {} + bool isAvailable() const + { + return false; + } + void start() {} + void stop() {} +}; + +#endif // Q_OS_MACOS + +AutoFill::AutoFill(QObject* parent) + : QObject(parent) + , d_ptr(new AutoFillPrivate(this)) +{ +} + +AutoFill::~AutoFill() +{ + delete d_ptr; +} + +bool AutoFill::isAvailable() const +{ + return d_ptr->isAvailable(); +} + +void AutoFill::start() +{ + d_ptr->start(); +} + +void AutoFill::stop() +{ + d_ptr->stop(); +} diff --git a/src/autofill/AutoFillProviderProtocol.h b/src/autofill/AutoFillProviderProtocol.h new file mode 100644 index 0000000000..aa677b8ca7 --- /dev/null +++ b/src/autofill/AutoFillProviderProtocol.h @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2024 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_AUTOFILL_PROVIDER_PROTOCOL_H +#define KEEPASSX_AUTOFILL_PROVIDER_PROTOCOL_H + +#import + +static NSString* const AutoFillCredentialRecordIdentifierKey = @"recordIdentifier"; +static NSString* const AutoFillCredentialTitleKey = @"title"; +static NSString* const AutoFillCredentialUsernameKey = @"username"; +static NSString* const AutoFillCredentialPasswordKey = @"password"; +static NSString* const AutoFillCredentialUrlKey = @"url"; +static NSString* const AutoFillCredentialDomainKey = @"domain"; +static NSString* const AutoFillCredentialOtpKey = @"otp"; + +@protocol AutoFillProviderProtocol + +- (void)fetchCredentialsMatchingDomain:(NSString*)domain + withReply:(void (^)(NSArray*>* credentials))reply; + +- (void)fetchCredentialWithRecordIdentifier:(NSString*)recordIdentifier + withReply:(void (^)(NSDictionary* credential))reply; + +@end + +#endif // KEEPASSX_AUTOFILL_PROVIDER_PROTOCOL_H diff --git a/src/autofill/AutoFillUtils.h b/src/autofill/AutoFillUtils.h new file mode 100644 index 0000000000..140c1a9c01 --- /dev/null +++ b/src/autofill/AutoFillUtils.h @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2024 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_AUTOFILL_UTILS_H +#define KEEPASSX_AUTOFILL_UTILS_H + +#include "gui/UrlTools.h" + +#include +#include +#include + +namespace AutoFillUtils +{ + +inline QString normalizeHost(const QString& host) +{ + auto normalized = host.trimmed().toLower(); + while (normalized.endsWith('.')) { + normalized.chop(1); + } + return normalized; +} + +inline QString hostFromUrl(const QString& value) +{ + if (value.trimmed().isEmpty()) { + return {}; + } + + QUrl url = QUrl::fromUserInput(value.trimmed()); + QString host = url.host().trimmed(); + if (host.isEmpty() && !value.contains('/')) { + host = value; + } + + host = normalizeHost(host); + if (host.isEmpty()) { + return {}; + } + + static const QRegularExpression kDomainRegex(QStringLiteral("^[a-z0-9.-]+$")); + if (!kDomainRegex.match(host).hasMatch()) { + return {}; + } + + return host; +} + +inline bool hostsMatch(const QString& requested, const QString& candidate) +{ + if (requested.isEmpty() || candidate.isEmpty()) { + return false; + } + + // Reject hosts that start with a dot (potential security issue) + if (requested.startsWith('.') || candidate.startsWith('.')) { + return false; + } + + if (requested == candidate) { + return true; + } + + // IP addresses require exact match only (already handled above) + if (urlTools()->isIpAddress(requested) || urlTools()->isIpAddress(candidate)) { + return false; + } + + // Base domains must match (follows BrowserService::handleURL pattern) + if (urlTools()->getBaseDomainFromUrl(requested) != urlTools()->getBaseDomainFromUrl(candidate)) { + return false; + } + + // Allow subdomain matching when one host ends with the other + if (requested.endsWith('.' + candidate) || candidate.endsWith('.' + requested)) { + return true; + } + + return false; +} + +} // namespace AutoFillUtils + +#endif // KEEPASSX_AUTOFILL_UTILS_H diff --git a/src/autofill/AutoFillXPCProtocol.h b/src/autofill/AutoFillXPCProtocol.h new file mode 100644 index 0000000000..1a6684da94 --- /dev/null +++ b/src/autofill/AutoFillXPCProtocol.h @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2024 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 AUTOFILLXPCPROTOCOL_H +#define AUTOFILLXPCPROTOCOL_H + +#import + +#import "AutoFillProviderProtocol.h" + +@protocol AutoFillXPCProtocol + +- (void)registerProvider:(NSXPCListenerEndpoint*)endpoint withReply:(void (^)(NSError* error))reply; +- (void)getLoginsForURL:(NSString*)url withReply:(void (^)(NSArray*>* logins))reply; +- (void)getCredentialWithRecordIdentifier:(NSString*)recordIdentifier + withReply:(void (^)(NSDictionary* credential))reply; + +@end + +#endif // AUTOFILLXPCPROTOCOL_H diff --git a/src/autofill/AutoFillXPCService-Info.plist b/src/autofill/AutoFillXPCService-Info.plist new file mode 100644 index 0000000000..79170de7a9 --- /dev/null +++ b/src/autofill/AutoFillXPCService-Info.plist @@ -0,0 +1,29 @@ + + + + + CFBundleExecutable + keepassxc-autofill-xpc + CFBundleIdentifier + org.keepassxc.KeePassXC.AutoFill-XPC-Service + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + keepassxc-autofill-xpc + CFBundlePackageType + XPC! + CFBundleShortVersionString + @KEEPASSXC_VERSION@ + CFBundleVersion + @KEEPASSXC_VERSION@ + LSMinimumSystemVersion + 11.0 + XPCService + + ServiceType + Application + JoinExistingSession + + + + diff --git a/src/autofill/AutoFillXPCService.m b/src/autofill/AutoFillXPCService.m new file mode 100644 index 0000000000..28a58cd4c0 --- /dev/null +++ b/src/autofill/AutoFillXPCService.m @@ -0,0 +1,153 @@ +/* + * Copyright (C) 2024 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 . + */ + +#import "AutoFillXPCProtocol.h" +#import +#import +#import + +@interface AutoFillXPCService : NSObject { + NSXPCConnection* _providerConnection; +} + +@property(nonatomic, strong) NSXPCListenerEndpoint* providerEndpoint; +@property(nonatomic, strong) dispatch_queue_t dispatchQueue; + +- (NSXPCConnection*)connection; + +@end + +@implementation AutoFillXPCService + +- (instancetype)init +{ + self = [super init]; + if (self) { + _dispatchQueue = dispatch_queue_create("org.keepassxc.autofill.service.queue", DISPATCH_QUEUE_SERIAL); + } + return self; +} + +- (BOOL)listener:(NSXPCListener *)listener shouldAcceptNewConnection:(NSXPCConnection *)newConnection { + newConnection.exportedInterface = [NSXPCInterface interfaceWithProtocol:@protocol(AutoFillXPCProtocol)]; + newConnection.exportedObject = self; + [newConnection resume]; + return YES; +} + +- (void)registerProvider:(NSXPCListenerEndpoint*)endpoint withReply:(void (^)(NSError* error))reply +{ + void (^replyCopy)(NSError* error) = [reply copy]; + dispatch_async(self.dispatchQueue, ^{ + self.providerEndpoint = endpoint; + if (replyCopy) { + replyCopy(nil); + } + }); +} + +- (void)getLoginsForURL:(NSString*)url withReply:(void (^)(NSArray*>* logins))reply +{ + void (^replyCopy)(NSArray*>* logins) = [reply copy]; + dispatch_async(self.dispatchQueue, ^{ + NSXPCConnection* conn = self.connection; + if (!conn) { + os_log_error(OS_LOG_DEFAULT, "AutoFill XPC service: no provider connection available"); + if (replyCopy) { + replyCopy(@[]); + } + return; + } + id provider = conn.remoteObjectProxy; + [provider fetchCredentialsMatchingDomain:url + withReply:^(NSArray*>* credentials) { + if (replyCopy) { + replyCopy(credentials ?: @[]); + } + }]; + }); +} + +- (void)getCredentialWithRecordIdentifier:(NSString*)recordIdentifier + withReply:(void (^)(NSDictionary* credential))reply +{ + void (^replyCopy)(NSDictionary* credential) = [reply copy]; + dispatch_async(self.dispatchQueue, ^{ + NSXPCConnection* conn = self.connection; + if (!conn) { + os_log_error(OS_LOG_DEFAULT, "AutoFill XPC service: no provider connection available"); + if (replyCopy) { + replyCopy(@{}); + } + return; + } + id provider = conn.remoteObjectProxy; + [provider fetchCredentialWithRecordIdentifier:recordIdentifier + withReply:^(NSDictionary* credential) { + if (replyCopy) { + replyCopy(credential ?: @{}); + } + }]; + }); +} + +- (NSXPCConnection*)connection +{ + if (_providerConnection) { + return _providerConnection; + } + + if (!self.providerEndpoint) { + os_log_error(OS_LOG_DEFAULT, "AutoFill service does not have provider endpoint"); + return nil; + } + + _providerConnection = [[NSXPCConnection alloc] initWithListenerEndpoint:self.providerEndpoint]; + _providerConnection.remoteObjectInterface = [NSXPCInterface interfaceWithProtocol:@protocol(AutoFillProviderProtocol)]; + + __weak AutoFillXPCService* weakSelf = self; + _providerConnection.interruptionHandler = ^{ + os_log_info(OS_LOG_DEFAULT, "AutoFill service provider connection interrupted"); + }; + _providerConnection.invalidationHandler = ^{ + os_log_info(OS_LOG_DEFAULT, "AutoFill service provider connection invalidated"); + AutoFillXPCService* strongSelf = weakSelf; + if (strongSelf) { + dispatch_async(strongSelf.dispatchQueue, ^{ + strongSelf->_providerConnection = nil; + }); + } + }; + + [_providerConnection resume]; + + return _providerConnection; +} + +@end + +int main(void) { + @autoreleasepool { + os_log(OS_LOG_DEFAULT, "KeePassXC AutoFill XPC Service starting."); + NSXPCListener *listener = [NSXPCListener serviceListener]; + AutoFillXPCService *service = [[AutoFillXPCService alloc] init]; + listener.delegate = service; + [listener resume]; + [[NSRunLoop currentRunLoop] run]; + } + return 0; +} diff --git a/src/autofill/CredentialProviderViewController.mm b/src/autofill/CredentialProviderViewController.mm new file mode 100644 index 0000000000..e52c1406df --- /dev/null +++ b/src/autofill/CredentialProviderViewController.mm @@ -0,0 +1,528 @@ +/* + * Copyright (C) 2024 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 . + */ + +#import +#import +#import + +#import "AutoFillProviderProtocol.h" +#import "AutoFillXPCProtocol.h" + +typedef NS_ENUM(NSInteger, KPAFCredentialListState) +{ + KPAFCredentialListStateIdle = 0, + KPAFCredentialListStateLoading, + KPAFCredentialListStateEmpty, + KPAFCredentialListStateError, + KPAFCredentialListStatePopulated, +}; + +static NSString* const KPAFEmptyMessage = @"Unlock KeePassXC to expose matching entries."; +static NSString* const KPAFErrorMessage = + @"Unable to contact KeePassXC. Make sure the app is running and the database is unlocked."; + +@interface KPAFCredential : NSObject + +@property(nonatomic, copy, readonly) NSString* recordIdentifier; +@property(nonatomic, copy, readonly) NSString* username; +@property(nonatomic, copy, readonly) NSString* password; +@property(nonatomic, copy, readonly) NSString* title; +@property(nonatomic, copy, readonly) NSString* domain; +@property(nonatomic, copy, readonly) NSString* url; + +- (nullable instancetype)initWithDictionary:(NSDictionary*)dictionary; + +@end + +@implementation KPAFCredential + +- (instancetype)initWithDictionary:(NSDictionary*)dictionary +{ + self = [super init]; + if (!self) { + return nil; + } + + NSString* recordIdentifier = dictionary[AutoFillCredentialRecordIdentifierKey]; + NSString* username = dictionary[AutoFillCredentialUsernameKey]; + NSString* password = dictionary[AutoFillCredentialPasswordKey]; + NSString* title = dictionary[AutoFillCredentialTitleKey]; + NSString* domain = dictionary[AutoFillCredentialDomainKey]; + NSString* url = dictionary[AutoFillCredentialUrlKey]; + if (!recordIdentifier || !username || !password || !title || !domain || !url) { + return nil; + } + + _recordIdentifier = [recordIdentifier copy]; + _username = [username copy]; + _password = [password copy]; + _title = [title copy]; + _domain = [domain copy]; + _url = [url copy]; + return self; +} + +@end + +typedef void (^KPAFCredentialsCompletion)(NSArray* _Nullable credentials, NSError* _Nullable error); +typedef void (^KPAFCredentialCompletion)(KPAFCredential* _Nullable credential, NSError* _Nullable error); + +@interface AutoFillServiceClient : NSObject + ++ (instancetype)sharedClient; +- (void)fetchCredentialsForDomain:(NSString* _Nullable)domain completion:(KPAFCredentialsCompletion)completion; +- (void)fetchCredentialWithRecordIdentifier:(NSString*)recordIdentifier completion:(KPAFCredentialCompletion)completion; + +@end + +@implementation AutoFillServiceClient +{ + NSXPCConnection* m_connection; + os_log_t m_log; +} + ++ (instancetype)sharedClient +{ + static AutoFillServiceClient* sharedInstance = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + sharedInstance = [[self alloc] init]; + }); + return sharedInstance; +} + +- (instancetype)init +{ + self = [super init]; + if (self) { + m_log = os_log_create("org.keepassxc.keepassxc", "AutoFillClient"); + } + return self; +} + +- (NSXPCConnection*)connection +{ + if (m_connection) { + return m_connection; + } + + NSXPCConnection* connection = [[NSXPCConnection alloc] initWithServiceName:@"org.keepassxc.KeePassXC.AutoFill-XPC-Service"]; + connection.remoteObjectInterface = [NSXPCInterface interfaceWithProtocol:@protocol(AutoFillXPCProtocol)]; + __weak AutoFillServiceClient* weakSelf = self; + __weak NSXPCConnection* weakConnection = connection; + connection.invalidationHandler = ^{ + AutoFillServiceClient* strongSelf = weakSelf; + if (strongSelf) { + os_log_error(strongSelf->m_log, "Lost AutoFill XPC connection."); + if (weakConnection == strongSelf->m_connection) { + strongSelf->m_connection = nil; + } + } + }; + [connection resume]; + m_connection = connection; + return m_connection; +} + +- (id)proxyWithError:(void (^)(NSError*))errorHandler +{ + NSXPCConnection* connection = [self connection]; + id remoteObject = [connection remoteObjectProxyWithErrorHandler:^(NSError* error) { + os_log_error(m_log, "AutoFill XPC proxy failed: %{public}@", error.localizedDescription); + if (errorHandler) { + errorHandler(error); + } + }]; + return [remoteObject conformsToProtocol:@protocol(AutoFillXPCProtocol)] ? remoteObject : nil; +} + +- (void)fetchCredentialsForDomain:(NSString*)domain completion:(KPAFCredentialsCompletion)completion +{ + id proxy = [self proxyWithError:^(NSError* error) { + if (completion) { + completion(nil, error); + } + }]; + if (!proxy) { + if (completion) { + NSError* err = [NSError errorWithDomain:NSPOSIXErrorDomain code:ENOTCONN userInfo:nil]; + completion(nil, err); + } + return; + } + + [proxy getLoginsForURL:domain ?: @"" + withReply:^(NSArray*>* payload) { + NSMutableArray* credentials = [NSMutableArray array]; + for (NSDictionary* entry in payload) { + KPAFCredential* credential = [[KPAFCredential alloc] initWithDictionary:entry]; + if (credential) { + [credentials addObject:credential]; + } + } + if (completion) { + completion(credentials, nil); + } + }]; +} + +- (void)fetchCredentialWithRecordIdentifier:(NSString*)recordIdentifier completion:(KPAFCredentialCompletion)completion +{ + id proxy = [self proxyWithError:^(NSError* error) { + if (completion) { + completion(nil, error); + } + }]; + if (!proxy) { + if (completion) { + NSError* err = [NSError errorWithDomain:NSPOSIXErrorDomain code:ENOTCONN userInfo:nil]; + completion(nil, err); + } + return; + } + + [proxy getCredentialWithRecordIdentifier:recordIdentifier + withReply:^(NSDictionary* payload) { + KPAFCredential* credential = [[KPAFCredential alloc] initWithDictionary:payload]; + if (completion) { + if (credential) { + completion(credential, nil); + } else { + NSError* err = + [NSError errorWithDomain:NSPOSIXErrorDomain code:ENOENT userInfo:nil]; + completion(nil, err); + } + } + }]; +} + +@end + +@interface CredentialProviderViewController : + ASCredentialProviderViewController + +@property(nonatomic) KPAFCredentialListState state; +@property(nonatomic, copy) NSString* statusMessage; +@property(nonatomic, strong) NSMutableArray* credentials; +@property(nonatomic, copy) NSString* currentDomain; +@property(nonatomic, strong) NSScrollView* scrollView; +@property(nonatomic, strong) NSTableView* tableView; +@property(nonatomic, strong) NSTextField* statusLabel; +@property(nonatomic, strong) NSProgressIndicator* activityIndicator; +@property(nonatomic) os_log_t log; + +@end + +@implementation CredentialProviderViewController + +- (instancetype)init +{ + self = [super initWithNibName:nil bundle:nil]; + if (self) { + _credentials = [NSMutableArray array]; + _state = KPAFCredentialListStateIdle; + _statusMessage = @""; + _log = os_log_create("org.keepassxc.keepassxc", "AutoFillUI"); + } + return self; +} + +- (void)loadView +{ + self.view = [[NSView alloc] initWithFrame:NSZeroRect]; + self.view.translatesAutoresizingMaskIntoConstraints = NO; + [self setupTableView]; + [self setupStatusLabel]; + [self setupActivityIndicator]; +} + +- (void)viewDidLoad +{ + [super viewDidLoad]; + [self updateState]; +} + +- (void)prepareCredentialListForServiceIdentifiers:(NSArray*)serviceIdentifiers +{ + self.currentDomain = serviceIdentifiers.firstObject.identifier; + [self fetchCredentialsForDomain:self.currentDomain]; +} + +- (void)provideCredentialWithoutUserInteractionForCredentialIdentity:(ASPasswordCredentialIdentity*)credentialIdentity + NS_SWIFT_NAME(provideCredentialWithoutUserInteraction(for:)) +{ + NSString* identifier = credentialIdentity.recordIdentifier; + if (!identifier.length) { + NSError* error = [NSError errorWithDomain:ASExtensionErrorDomain + code:ASExtensionErrorCodeFailed + userInfo:nil]; + [self.extensionContext cancelRequestWithError:error]; + return; + } + [self fetchCredentialWithIdentifier:identifier silently:YES]; +} + +- (void)prepareInterfaceToProvideCredentialForCredentialIdentity:(ASPasswordCredentialIdentity*)credentialIdentity +{ + NSString* identifier = credentialIdentity.recordIdentifier; + if (!identifier.length) { + NSError* error = [NSError errorWithDomain:ASExtensionErrorDomain + code:ASExtensionErrorCodeFailed + userInfo:nil]; + [self.extensionContext cancelRequestWithError:error]; + return; + } + [self fetchCredentialWithIdentifier:identifier silently:NO]; +} + +- (void)setupTableView +{ + NSTableColumn* titleColumn = [[NSTableColumn alloc] initWithIdentifier:@"title"]; + titleColumn.title = @"Item"; + titleColumn.width = 240.0; + + NSTableColumn* userColumn = [[NSTableColumn alloc] initWithIdentifier:@"username"]; + userColumn.title = @"Username"; + userColumn.width = 220.0; + + self.tableView = [[NSTableView alloc] initWithFrame:NSZeroRect]; + [self.tableView addTableColumn:titleColumn]; + [self.tableView addTableColumn:userColumn]; + self.tableView.delegate = self; + self.tableView.dataSource = self; + self.tableView.usesAlternatingRowBackgroundColors = YES; + self.tableView.selectionHighlightStyle = NSTableViewSelectionHighlightStyleRegular; + self.tableView.doubleAction = @selector(didDoubleClickRow:); + self.tableView.target = self; + + self.scrollView = [[NSScrollView alloc] initWithFrame:NSZeroRect]; + self.scrollView.translatesAutoresizingMaskIntoConstraints = NO; + self.scrollView.hasVerticalScroller = YES; + self.scrollView.documentView = self.tableView; + [self.view addSubview:self.scrollView]; + + [NSLayoutConstraint activateConstraints:@[ + [self.scrollView.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor], + [self.scrollView.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor], + [self.scrollView.topAnchor constraintEqualToAnchor:self.view.topAnchor], + [self.scrollView.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor], + ]]; +} + +- (void)setupStatusLabel +{ + self.statusLabel = [NSTextField labelWithString:@""]; + self.statusLabel.translatesAutoresizingMaskIntoConstraints = NO; + self.statusLabel.alignment = NSTextAlignmentCenter; + self.statusLabel.lineBreakMode = NSLineBreakByWordWrapping; + self.statusLabel.hidden = YES; + [self.view addSubview:self.statusLabel]; + + [NSLayoutConstraint activateConstraints:@[ + [self.statusLabel.centerXAnchor constraintEqualToAnchor:self.view.centerXAnchor], + [self.statusLabel.centerYAnchor constraintEqualToAnchor:self.view.centerYAnchor], + [self.statusLabel.leadingAnchor constraintGreaterThanOrEqualToAnchor:self.view.leadingAnchor constant:16.0], + [self.statusLabel.trailingAnchor constraintLessThanOrEqualToAnchor:self.view.trailingAnchor constant:-16.0], + ]]; +} + +- (void)setupActivityIndicator +{ + self.activityIndicator = [[NSProgressIndicator alloc] initWithFrame:NSZeroRect]; + self.activityIndicator.translatesAutoresizingMaskIntoConstraints = NO; + self.activityIndicator.style = NSProgressIndicatorStyleSpinning; + self.activityIndicator.controlSize = NSControlSizeRegular; + self.activityIndicator.displayedWhenStopped = NO; + [self.view addSubview:self.activityIndicator]; + + [NSLayoutConstraint activateConstraints:@[ + [self.activityIndicator.centerXAnchor constraintEqualToAnchor:self.view.centerXAnchor], + [self.activityIndicator.centerYAnchor constraintEqualToAnchor:self.view.centerYAnchor], + ]]; +} + +- (void)updateState +{ + __weak typeof(self) weakSelf = self; + dispatch_async(dispatch_get_main_queue(), ^{ + if (!weakSelf) { + return; + } + switch (weakSelf.state) { + case KPAFCredentialListStateIdle: + [weakSelf.activityIndicator stopAnimation:nil]; + weakSelf.statusLabel.hidden = YES; + weakSelf.scrollView.hidden = YES; + break; + case KPAFCredentialListStateLoading: + weakSelf.statusLabel.hidden = YES; + weakSelf.scrollView.hidden = YES; + [weakSelf.activityIndicator startAnimation:nil]; + break; + case KPAFCredentialListStatePopulated: + [weakSelf.activityIndicator stopAnimation:nil]; + weakSelf.statusLabel.hidden = YES; + weakSelf.scrollView.hidden = NO; + break; + case KPAFCredentialListStateEmpty: + case KPAFCredentialListStateError: + [weakSelf.activityIndicator stopAnimation:nil]; + weakSelf.scrollView.hidden = YES; + weakSelf.statusLabel.stringValue = weakSelf.statusMessage ?: @""; + weakSelf.statusLabel.hidden = NO; + break; + } + }); +} + +- (void)transitionToState:(KPAFCredentialListState)state message:(NSString*)message +{ + self.state = state; + self.statusMessage = message ?: @""; + [self updateState]; +} + +- (void)fetchCredentialsForDomain:(NSString*)domain +{ + [self transitionToState:KPAFCredentialListStateLoading message:nil]; + __weak typeof(self) weakSelf = self; + [[AutoFillServiceClient sharedClient] fetchCredentialsForDomain:domain + completion:^(NSArray* _Nullable credentials, + NSError* _Nullable error) { + dispatch_async(dispatch_get_main_queue(), ^{ + if (!weakSelf) { + return; + } + if (error) { + os_log_error(weakSelf.log, + "Failed to fetch credentials: %{public}@", + error.localizedDescription); + [weakSelf transitionToState:KPAFCredentialListStateError + message:KPAFErrorMessage]; + return; + } + [weakSelf.credentials removeAllObjects]; + [weakSelf.credentials addObjectsFromArray:credentials]; + [weakSelf.tableView reloadData]; + if (weakSelf.credentials.count == 0) { + [weakSelf transitionToState:KPAFCredentialListStateEmpty + message:KPAFEmptyMessage]; + } else { + [weakSelf transitionToState:KPAFCredentialListStatePopulated + message:nil]; + } + }); + }]; +} + +- (void)fetchCredentialWithIdentifier:(NSString*)identifier silently:(BOOL)silently +{ + if (!silently) { + [self transitionToState:KPAFCredentialListStateLoading message:nil]; + } + __weak typeof(self) weakSelf = self; + [[AutoFillServiceClient sharedClient] fetchCredentialWithRecordIdentifier:identifier + completion:^(KPAFCredential* _Nullable credential, + NSError* _Nullable error) { + dispatch_async(dispatch_get_main_queue(), ^{ + if (!weakSelf) { + return; + } + if (credential) { + [weakSelf completeWithCredential:credential]; + } else { + os_log_error(weakSelf.log, + "Failed to fetch credential: %{public}@", + error.localizedDescription); + NSError* extensionError = + [NSError errorWithDomain:ASExtensionErrorDomain + code:ASExtensionErrorCodeCredentialIdentityNotFound + userInfo:nil]; + [weakSelf.extensionContext + cancelRequestWithError:extensionError]; + } + }); + }]; +} + +- (void)completeWithCredential:(KPAFCredential*)credential +{ + ASPasswordCredential* passwordCredential = + [[ASPasswordCredential alloc] initWithUser:credential.username password:credential.password]; + [self.extensionContext completeRequestWithSelectedCredential:passwordCredential completionHandler:nil]; +} + +- (void)didDoubleClickRow:(id)sender +{ + NSInteger row = self.tableView.clickedRow; + if (row < 0 || row >= static_cast(self.credentials.count)) { + return; + } + [self completeWithCredential:self.credentials[row]]; +} + +#pragma mark - NSTableViewDataSource + +- (NSInteger)numberOfRowsInTableView:(NSTableView*)tableView +{ + return static_cast(self.credentials.count); +} + +#pragma mark - NSTableViewDelegate + +- (NSView*)tableView:(NSTableView*)tableView viewForTableColumn:(NSTableColumn*)tableColumn row:(NSInteger)row +{ + if (row < 0 || row >= static_cast(self.credentials.count)) { + return nil; + } + + NSString* identifier = tableColumn.identifier; + KPAFCredential* credential = self.credentials[row]; + NSString* text = nil; + if ([identifier isEqualToString:@"username"]) { + text = credential.username.length ? credential.username : @""; + } else { + text = credential.title.length ? credential.title : credential.domain; + } + + NSTableCellView* cellView = + [tableView makeViewWithIdentifier:tableColumn.identifier owner:self]; + if (!cellView) { + cellView = [[NSTableCellView alloc] initWithFrame:NSZeroRect]; + cellView.identifier = tableColumn.identifier; + NSTextField* textField = [NSTextField labelWithString:text ?: @""]; + textField.translatesAutoresizingMaskIntoConstraints = NO; + [cellView addSubview:textField]; + cellView.textField = textField; + [NSLayoutConstraint activateConstraints:@[ + [textField.leadingAnchor constraintEqualToAnchor:cellView.leadingAnchor constant:4.0], + [textField.trailingAnchor constraintEqualToAnchor:cellView.trailingAnchor constant:-4.0], + [textField.topAnchor constraintEqualToAnchor:cellView.topAnchor constant:2.0], + [textField.bottomAnchor constraintEqualToAnchor:cellView.bottomAnchor constant:-2.0], + ]]; + } + + cellView.textField.stringValue = text ?: @""; + return cellView; +} + +- (BOOL)tableView:(NSTableView*)tableView shouldSelectRow:(NSInteger)row +{ + return row >= 0 && row < static_cast(self.credentials.count); +} + +@end diff --git a/src/autofill/mac/Info.plist.in b/src/autofill/mac/Info.plist.in new file mode 100644 index 0000000000..5931b0f3dd --- /dev/null +++ b/src/autofill/mac/Info.plist.in @@ -0,0 +1,45 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + KeePassXC AutoFill + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + @MACOSX_EXTENSION_BUNDLE_ID@ + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + BNDL + LSMinimumSystemVersion + 12.0 + CFBundleShortVersionString + @KEEPASSXC_VERSION@ + CFBundleVersion + @KEEPASSXC_VERSION@ + NSExtension + + NSExtensionAttributes + + ASCredentialProviderExtensionCapabilities + + ProvidesPasswords + + ProvidesPasskeys + + ProvidesOneTimeCodes + + + + NSExtensionPointIdentifier + com.apple.authentication-services-credential-provider-ui + NSExtensionPrincipalClass + CredentialProviderViewController + + + diff --git a/src/autofill/mac/MacAutoFill.entitlements b/src/autofill/mac/MacAutoFill.entitlements new file mode 100644 index 0000000000..680a79fb45 --- /dev/null +++ b/src/autofill/mac/MacAutoFill.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.developer.authentication-services.autofill-credential-provider + + com.apple.application-identifier + @MACOSX_EXTENSION_BUNDLE_ID@ + keychain-access-groups + + @MACOSX_EXTENSION_BUNDLE_ID@ + + + diff --git a/src/core/Config.cpp b/src/core/Config.cpp index 3860a266fd..2024e289b5 100644 --- a/src/core/Config.cpp +++ b/src/core/Config.cpp @@ -156,6 +156,7 @@ static const QHash configStrings = { {Config::Security_EnableCopyOnDoubleClick,{QS("Security/EnableCopyOnDoubleClick"), Roaming, false}}, {Config::Security_QuickUnlock, {QS("Security/QuickUnlock"), Local, true}}, {Config::Security_DatabasePasswordMinimumQuality, {QS("Security/DatabasePasswordMinimumQuality"), Local, 0}}, + {Config::Security_macOSAutoFill, {QS("Security/macOSAutoFill"), Local, false}}, // Browser {Config::Browser_Enabled, {QS("Browser/Enabled"), Roaming, false}}, diff --git a/src/core/Config.h b/src/core/Config.h index 8f54f9c013..acdb2294fa 100644 --- a/src/core/Config.h +++ b/src/core/Config.h @@ -137,6 +137,7 @@ class Config : public QObject Security_EnableCopyOnDoubleClick, Security_QuickUnlock, Security_DatabasePasswordMinimumQuality, + Security_macOSAutoFill, Browser_Enabled, Browser_ShowNotification, diff --git a/src/gui/ApplicationSettingsWidget.cpp b/src/gui/ApplicationSettingsWidget.cpp index 9f24bbc85e..adaa2126a6 100644 --- a/src/gui/ApplicationSettingsWidget.cpp +++ b/src/gui/ApplicationSettingsWidget.cpp @@ -33,6 +33,9 @@ #include "gui/osutils/OSUtils.h" #include "gui/styles/StateColorPalette.h" #include "quickunlock/QuickUnlockInterface.h" +#ifdef Q_OS_MACOS +#include "autofill/AutoFill.h" +#endif #include "FileDialog.h" #include "MessageBox.h" @@ -351,6 +354,12 @@ void ApplicationSettingsWidget::loadSettings() m_secUi->quickUnlockCheckBox->setEnabled(getQuickUnlock()->isAvailable()); m_secUi->quickUnlockCheckBox->setChecked(config()->get(Config::Security_QuickUnlock).toBool()); +#if defined(Q_OS_MACOS) + m_generalUi->macOSAutoFillCheckBox->setChecked(config()->get(Config::Security_macOSAutoFill).toBool()); +#else + m_generalUi->macOSGroup->setVisible(false); +#endif + for (const ExtraPage& page : asConst(m_extraPages)) { page.loadSettings(); } @@ -473,6 +482,18 @@ void ApplicationSettingsWidget::saveSettings() config()->set(Config::Security_QuickUnlock, m_secUi->quickUnlockCheckBox->isChecked()); } +#if defined(Q_OS_MACOS) + { + bool autoFillEnabled = m_generalUi->macOSAutoFillCheckBox->isChecked(); + config()->set(Config::Security_macOSAutoFill, autoFillEnabled); + if (autoFillEnabled) { + getMainWindow()->autoFill()->start(); + } else { + getMainWindow()->autoFill()->stop(); + } + } +#endif + // Security: clear storage if related settings are disabled if (!config()->get(Config::RememberLastDatabases).toBool()) { config()->remove(Config::LastDir); diff --git a/src/gui/ApplicationSettingsWidgetGeneral.ui b/src/gui/ApplicationSettingsWidgetGeneral.ui index 42ce4acc16..4c6fa7ca83 100644 --- a/src/gui/ApplicationSettingsWidgetGeneral.ui +++ b/src/gui/ApplicationSettingsWidgetGeneral.ui @@ -753,6 +753,22 @@ + + + + macOS Integration + + + + + + Enable macOS AutoFill Integration + + + + + + diff --git a/src/gui/MainWindow.cpp b/src/gui/MainWindow.cpp index 755b38a1e0..537913c36e 100644 --- a/src/gui/MainWindow.cpp +++ b/src/gui/MainWindow.cpp @@ -49,6 +49,10 @@ #include "gui/osutils/OSUtils.h" #include "gui/remote/RemoteSettings.h" +#ifdef Q_OS_MACOS +#include "autofill/AutoFill.h" +#endif + #ifdef WITH_XC_UPDATECHECK #include "gui/UpdateCheckDialog.h" #include "networking/UpdateChecker.h" @@ -96,6 +100,10 @@ MainWindow::MainWindow() #ifdef Q_OS_MACOS macUtils()->configureWindowAndHelpMenus(this, m_ui->menuHelp); + m_autoFill = new AutoFill(this); + if (m_autoFill->isAvailable() && config()->get(Config::Security_macOSAutoFill).toBool()) { + m_autoFill->start(); + } #endif #if defined(Q_OS_UNIX) && !defined(Q_OS_MACOS) && !defined(QT_NO_DBUS) diff --git a/src/gui/MainWindow.h b/src/gui/MainWindow.h index 229106c52d..a07d5604be 100644 --- a/src/gui/MainWindow.h +++ b/src/gui/MainWindow.h @@ -38,6 +38,9 @@ namespace Ui class InactivityTimer; class SearchWidget; class MainWindowEventFilter; +#ifdef Q_OS_MACOS +class AutoFill; +#endif class MainWindow : public QMainWindow { @@ -51,6 +54,10 @@ class MainWindow : public QMainWindow MainWindow(); ~MainWindow() override; +#ifdef Q_OS_MACOS + AutoFill* autoFill() const { return m_autoFill; } +#endif + QList getOpenDatabases(); void restoreConfigState(); void setAllowScreenCapture(bool state); @@ -189,6 +196,9 @@ private slots: QPointer m_progressBar; QPointer m_progressBarLabel; QPointer m_statusBarLabel; +#ifdef Q_OS_MACOS + AutoFill* m_autoFill = nullptr; +#endif Q_DISABLE_COPY(MainWindow) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 25b116d8e2..96d098944b 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -240,6 +240,7 @@ endif() if(WITH_XC_NETWORKING OR WITH_XC_BROWSER) add_unit_test(NAME testurltools SOURCES TestUrlTools.cpp LIBS ${TEST_LIBRARIES}) + add_unit_test(NAME testautofillutils SOURCES TestAutoFillUtils.cpp LIBS ${TEST_LIBRARIES}) endif() add_unit_test(NAME testcli SOURCES TestCli.cpp diff --git a/tests/TestAutoFillUtils.cpp b/tests/TestAutoFillUtils.cpp new file mode 100644 index 0000000000..54ee404f16 --- /dev/null +++ b/tests/TestAutoFillUtils.cpp @@ -0,0 +1,141 @@ +/* + * Copyright (C) 2024 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 "TestAutoFillUtils.h" + +#include "autofill/AutoFillUtils.h" + +#include + +QTEST_GUILESS_MAIN(TestAutoFillUtils) + +void TestAutoFillUtils::testNormalizeHost_data() +{ + QTest::addColumn("input"); + QTest::addColumn("expected"); + + QTest::newRow("Simple host") << "example.com" << "example.com"; + QTest::newRow("With trailing dot") << "example.com." << "example.com"; + QTest::newRow("With multiple trailing dots") << "example.com..." << "example.com"; + QTest::newRow("Uppercase") << "EXAMPLE.COM" << "example.com"; + QTest::newRow("Mixed case") << "ExAmPlE.CoM" << "example.com"; + QTest::newRow("Leading spaces") << " example.com" << "example.com"; + QTest::newRow("Trailing spaces") << "example.com " << "example.com"; + QTest::newRow("Both spaces and trailing dots") << " example.com.. " << "example.com"; + QTest::newRow("Empty string") << "" << ""; + QTest::newRow("Only spaces") << " " << ""; + QTest::newRow("Only dots") << "..." << ""; + QTest::newRow("Subdomain") << "www.example.com." << "www.example.com"; +} + +void TestAutoFillUtils::testNormalizeHost() +{ + QFETCH(QString, input); + QFETCH(QString, expected); + + QCOMPARE(AutoFillUtils::normalizeHost(input), expected); +} + +void TestAutoFillUtils::testHostFromUrl_data() +{ + QTest::addColumn("input"); + QTest::addColumn("expected"); + + // Valid URLs + QTest::newRow("Simple HTTPS URL") << "https://example.com" << "example.com"; + QTest::newRow("HTTPS with path") << "https://example.com/path/to/page" << "example.com"; + QTest::newRow("HTTPS with port") << "https://example.com:8080" << "example.com"; + QTest::newRow("HTTPS with query") << "https://example.com?query=1" << "example.com"; + QTest::newRow("HTTP URL") << "http://example.com" << "example.com"; + QTest::newRow("Subdomain") << "https://www.example.com" << "www.example.com"; + QTest::newRow("Deep subdomain") << "https://api.v2.example.com" << "api.v2.example.com"; + + // Domain only (no scheme) + QTest::newRow("Domain without scheme") << "example.com" << "example.com"; + QTest::newRow("Subdomain without scheme") << "www.example.com" << "www.example.com"; + + // Special cases + QTest::newRow("Localhost") << "localhost" << "localhost"; + QTest::newRow("Localhost with scheme") << "http://localhost" << "localhost"; + QTest::newRow("Localhost with port") << "http://localhost:3000" << "localhost"; + + // Invalid inputs + QTest::newRow("Empty string") << "" << ""; + QTest::newRow("Only spaces") << " " << ""; + QTest::newRow("Invalid characters") << "example<>.com" << ""; + QTest::newRow("Unicode domain") << "example\xC3\xA9.com" << ""; // Contains non-ASCII + + // Edge cases + QTest::newRow("Trailing dot URL") << "https://example.com." << "example.com"; + QTest::newRow("Uppercase URL") << "HTTPS://EXAMPLE.COM" << "example.com"; +} + +void TestAutoFillUtils::testHostFromUrl() +{ + QFETCH(QString, input); + QFETCH(QString, expected); + + QCOMPARE(AutoFillUtils::hostFromUrl(input), expected); +} + +void TestAutoFillUtils::testHostsMatch_data() +{ + QTest::addColumn("requested"); + QTest::addColumn("candidate"); + QTest::addColumn("expected"); + + // Exact matches + QTest::newRow("Exact match") << "example.com" << "example.com" << true; + QTest::newRow("Exact match with subdomain") << "www.example.com" << "www.example.com" << true; + + // Subdomain matching + QTest::newRow("Subdomain of candidate") << "www.example.com" << "example.com" << true; + QTest::newRow("Candidate is subdomain") << "example.com" << "www.example.com" << true; + QTest::newRow("Deep subdomain match") << "api.v2.example.com" << "example.com" << true; + QTest::newRow("Multiple subdomain levels") << "a.b.c.example.com" << "example.com" << true; + + // Non-matches + QTest::newRow("Different domains") << "example.com" << "other.com" << false; + QTest::newRow("Similar but different") << "myexample.com" << "example.com" << false; + QTest::newRow("Suffix match but not subdomain") << "notexample.com" << "example.com" << false; + QTest::newRow("Partial match") << "example" << "example.com" << false; + QTest::newRow("TLD mismatch") << "example.org" << "example.com" << false; + + // Empty cases + QTest::newRow("Empty requested") << "" << "example.com" << false; + QTest::newRow("Empty candidate") << "example.com" << "" << false; + QTest::newRow("Both empty") << "" << "" << false; + + // TLD / single-label abuse (must not leak credentials across unrelated domains) + QTest::newRow("Bare TLD candidate") << "evil.com" << "com" << false; + QTest::newRow("Bare TLD requested") << "com" << "evil.com" << false; + QTest::newRow("IP partial overlap") << "1.2.3.4" << "2.3.4" << false; + + // Edge cases + QTest::newRow("Same single label") << "localhost" << "localhost" << true; + QTest::newRow("Dot prefix attack") << ".example.com" << "example.com" << false; +} + +void TestAutoFillUtils::testHostsMatch() +{ + QFETCH(QString, requested); + QFETCH(QString, candidate); + QFETCH(bool, expected); + + QCOMPARE(AutoFillUtils::hostsMatch(requested, candidate), expected); +} + diff --git a/tests/TestAutoFillUtils.h b/tests/TestAutoFillUtils.h new file mode 100644 index 0000000000..164d2e72a9 --- /dev/null +++ b/tests/TestAutoFillUtils.h @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2024 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_TESTAUTOFILLUTILS_H +#define KEEPASSX_TESTAUTOFILLUTILS_H + +#include + +class TestAutoFillUtils : public QObject +{ + Q_OBJECT + +private slots: + void testNormalizeHost(); + void testNormalizeHost_data(); + void testHostFromUrl(); + void testHostFromUrl_data(); + void testHostsMatch(); + void testHostsMatch_data(); +}; + +#endif // KEEPASSX_TESTAUTOFILLUTILS_H