This commit is contained in:
Felix Berlakovich 2026-03-09 18:59:34 +01:00 committed by GitHub
commit 4f182b78cb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 2121 additions and 2 deletions

View file

@ -465,6 +465,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)

View file

@ -8,5 +8,7 @@
<array>
<string>G2S7P7J672.org.keepassxc.keepassxc</string>
</array>
<key>com.apple.developer.authentication-services.autofill-credential-provider</key>
<true/>
</dict>
</plist>

View file

@ -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()
@ -605,3 +610,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 "$<TARGET_BUNDLE_CONTENT_DIR:${PROGNAME}>/PlugIns"
COMMAND ${CMAKE_COMMAND} -E copy_directory
"$<TARGET_BUNDLE_DIR:${AUTOFILL_TARGET}>"
"$<TARGET_BUNDLE_CONTENT_DIR:${PROGNAME}>/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 "$<TARGET_BUNDLE_CONTENT_DIR:${PROGNAME}>/XPCServices"
COMMAND ${CMAKE_COMMAND} -E copy_directory
"$<TARGET_BUNDLE_DIR:${AUTOFILL_XPC_TARGET}>"
"$<TARGET_BUNDLE_CONTENT_DIR:${PROGNAME}>/XPCServices/${AUTOFILL_XPC_TARGET}.xpc"
COMMENT "Embedding AutoFill XPC service into app bundle"
)
endif()

45
src/autofill/AutoFill.h Normal file
View file

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

759
src/autofill/AutoFill.mm Normal file
View file

@ -0,0 +1,759 @@
/*
* Copyright (C) 2024 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 or (at your option)
* version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#include "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 <QCoreApplication>
#include <QtGlobal>
#include <QLoggingCategory>
#include <QPointer>
#include <QRegularExpression>
#include <QSet>
#include <QSharedPointer>
#include <QStringList>
#include <QTimer>
#include <QUrl>
#include <QVector>
#include <algorithm>
#ifdef Q_OS_MACOS
#import <AuthenticationServices/AuthenticationServices.h>
#import <Foundation/Foundation.h>
#import <dispatch/dispatch.h>
#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<NSDictionary<NSString*, id>*>*));
void fetchCredentialWithRecordIdentifier(const QString& recordId, void (^reply)(NSDictionary<NSString*, id>*));
private:
void connectSignals();
void watchExistingDatabases();
void watchDatabase(DatabaseWidget* widget);
void scheduleIdentityRefresh();
void refreshIdentityStore();
void clearIdentityStore();
void ensureListener();
void connectToServiceIfNeeded();
void handleServiceInvalidation();
QVector<CredentialRecord> collectCredentialsForDomain(const QString& domain) const;
QVector<CredentialRecord> collectAllCredentialRecords() const;
CredentialRecord buildRecord(const QSharedPointer<Database>& database,
Entry* entry,
const QString& domain,
const QString& sourceUrl) const;
QStringList entryDomains(Entry* entry) const;
QString recordIdentifierFor(const QSharedPointer<Database>& database, Entry* entry) const;
Entry* entryForRecordIdentifier(const QString& recordId, QSharedPointer<Database>& database) const;
DatabaseWidget* databaseWidgetForUuid(const QUuid& uuid) const;
NSArray<NSDictionary<NSString*, id>*>* serializeCredentialList(const QVector<CredentialRecord>& records) const;
NSDictionary<NSString*, id>* serializeCredential(const CredentialRecord& record) const;
bool m_available{false};
bool m_running{false};
bool m_signalsConnected{false};
bool m_serviceRegistered{false};
QSet<DatabaseWidget*> m_watchedDatabases;
QTimer* m_identityRefreshTimer{nullptr};
NSXPCConnection* m_serviceConnection{nil};
NSXPCListener* m_listener{nil};
AutoFillHostAdapter* m_hostAdapter{nil};
};
@interface AutoFillHostAdapter : NSObject <NSXPCListenerDelegate, AutoFillProviderProtocol>
@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<NSDictionary<NSString*, id>*>*))reply
{
if (!reply) {
return;
}
void (^replyCopy)(NSArray<NSDictionary<NSString*, id>*>*) = [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<NSString*, id>*))reply
{
if (!reply) {
return;
}
void (^replyCopy)(NSDictionary<NSString*, id>*) = [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<NSDictionary<NSString*, id>*>*))
{
if (!reply) {
return;
}
void (^replyCopy)(NSArray<NSDictionary<NSString*, id>*>*) = [reply copy];
QPointer<AutoFillPrivate> 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<NSString*, id>*))
{
if (!reply) {
return;
}
void (^replyCopy)(NSDictionary<NSString*, id>*) = [reply copy];
QPointer<AutoFillPrivate> guard(this);
QMetaObject::invokeMethod(this,
[guard, replyCopy, recordId]() {
if (!guard) {
replyCopy(@{});
return;
}
QSharedPointer<Database> 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<Database>&, const QSharedPointer<Database>&) {
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<AutoFillPrivate> guard(this);
m_serviceConnection.invalidationHandler = ^{
dispatch_async(dispatch_get_main_queue(), ^{
if (guard) {
guard->handleServiceInvalidation();
}
});
};
[m_serviceConnection resume];
id<AutoFillXPCProtocol> 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::CredentialRecord> AutoFillPrivate::collectCredentialsForDomain(const QString& domain) const
{
QVector<CredentialRecord> 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::CredentialRecord> AutoFillPrivate::collectAllCredentialRecords() const
{
QVector<CredentialRecord> records;
QSet<QString> 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>& 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<QString> 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>& 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>& 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<NSDictionary<NSString*, id>*>* AutoFillPrivate::serializeCredentialList(
const QVector<CredentialRecord>& 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<NSString*, id>* 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();
}

View file

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

View file

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

View file

@ -0,0 +1,34 @@
/*
* Copyright (C) 2024 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 or (at your option)
* version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#ifndef AUTOFILLXPCPROTOCOL_H
#define AUTOFILLXPCPROTOCOL_H
#import <Foundation/Foundation.h>
#import "AutoFillProviderProtocol.h"
@protocol AutoFillXPCProtocol <NSObject>
- (void)registerProvider:(NSXPCListenerEndpoint*)endpoint withReply:(void (^)(NSError* error))reply;
- (void)getLoginsForURL:(NSString*)url withReply:(void (^)(NSArray<NSDictionary<NSString*, id>*>* logins))reply;
- (void)getCredentialWithRecordIdentifier:(NSString*)recordIdentifier
withReply:(void (^)(NSDictionary<NSString*, id>* credential))reply;
@end
#endif // AUTOFILLXPCPROTOCOL_H

View file

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>keepassxc-autofill-xpc</string>
<key>CFBundleIdentifier</key>
<string>org.keepassxc.KeePassXC.AutoFill-XPC-Service</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>keepassxc-autofill-xpc</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>@KEEPASSXC_VERSION@</string>
<key>CFBundleVersion</key>
<string>@KEEPASSXC_VERSION@</string>
<key>LSMinimumSystemVersion</key>
<string>11.0</string>
<key>XPCService</key>
<dict>
<key>ServiceType</key>
<string>Application</string>
<key>JoinExistingSession</key>
<true/>
</dict>
</dict>
</plist>

View file

@ -0,0 +1,153 @@
/*
* Copyright (C) 2024 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 or (at your option)
* version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#import "AutoFillXPCProtocol.h"
#import <Foundation/Foundation.h>
#import <dispatch/dispatch.h>
#import <os/log.h>
@interface AutoFillXPCService : NSObject <NSXPCListenerDelegate, AutoFillXPCProtocol> {
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<NSDictionary<NSString*, id>*>* logins))reply
{
void (^replyCopy)(NSArray<NSDictionary<NSString*, id>*>* 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<AutoFillProviderProtocol> provider = conn.remoteObjectProxy;
[provider fetchCredentialsMatchingDomain:url
withReply:^(NSArray<NSDictionary<NSString*, id>*>* credentials) {
if (replyCopy) {
replyCopy(credentials ?: @[]);
}
}];
});
}
- (void)getCredentialWithRecordIdentifier:(NSString*)recordIdentifier
withReply:(void (^)(NSDictionary<NSString*, id>* credential))reply
{
void (^replyCopy)(NSDictionary<NSString*, id>* 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<AutoFillProviderProtocol> provider = conn.remoteObjectProxy;
[provider fetchCredentialWithRecordIdentifier:recordIdentifier
withReply:^(NSDictionary<NSString*, id>* 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;
}

View file

@ -0,0 +1,528 @@
/*
* Copyright (C) 2024 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 or (at your option)
* version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#import <AuthenticationServices/AuthenticationServices.h>
#import <Cocoa/Cocoa.h>
#import <os/log.h>
#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<NSString*, id>*)dictionary;
@end
@implementation KPAFCredential
- (instancetype)initWithDictionary:(NSDictionary<NSString*, id>*)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<KPAFCredential*>* _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<AutoFillXPCProtocol>)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<AutoFillXPCProtocol> 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<NSDictionary<NSString*, id>*>* payload) {
NSMutableArray<KPAFCredential*>* credentials = [NSMutableArray array];
for (NSDictionary<NSString*, id>* 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<AutoFillXPCProtocol> 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<NSString*, id>* 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 <NSTableViewDataSource, NSTableViewDelegate>
@property(nonatomic) KPAFCredentialListState state;
@property(nonatomic, copy) NSString* statusMessage;
@property(nonatomic, strong) NSMutableArray<KPAFCredential*>* 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<ASCredentialServiceIdentifier*>*)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<KPAFCredential*>* _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<NSInteger>(self.credentials.count)) {
return;
}
[self completeWithCredential:self.credentials[row]];
}
#pragma mark - NSTableViewDataSource
- (NSInteger)numberOfRowsInTableView:(NSTableView*)tableView
{
return static_cast<NSInteger>(self.credentials.count);
}
#pragma mark - NSTableViewDelegate
- (NSView*)tableView:(NSTableView*)tableView viewForTableColumn:(NSTableColumn*)tableColumn row:(NSInteger)row
{
if (row < 0 || row >= static_cast<NSInteger>(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 : @"<no 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<NSInteger>(self.credentials.count);
}
@end

View file

@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>KeePassXC AutoFill</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>@MACOSX_EXTENSION_BUNDLE_ID@</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
<key>CFBundleShortVersionString</key>
<string>@KEEPASSXC_VERSION@</string>
<key>CFBundleVersion</key>
<string>@KEEPASSXC_VERSION@</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>ASCredentialProviderExtensionCapabilities</key>
<dict>
<key>ProvidesPasswords</key>
<true/>
<key>ProvidesPasskeys</key>
<false/>
<key>ProvidesOneTimeCodes</key>
<false/>
</dict>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.authentication-services-credential-provider-ui</string>
<key>NSExtensionPrincipalClass</key>
<string>CredentialProviderViewController</string>
</dict>
</dict>
</plist>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.authentication-services.autofill-credential-provider</key>
<true/>
<key>com.apple.application-identifier</key>
<string>@MACOSX_EXTENSION_BUNDLE_ID@</string>
<key>keychain-access-groups</key>
<array>
<string>@MACOSX_EXTENSION_BUNDLE_ID@</string>
</array>
</dict>
</plist>

View file

@ -156,6 +156,7 @@ static const QHash<Config::ConfigKey, ConfigDirective> 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}},

View file

@ -137,6 +137,7 @@ public:
Security_EnableCopyOnDoubleClick,
Security_QuickUnlock,
Security_DatabasePasswordMinimumQuality,
Security_macOSAutoFill,
Browser_Enabled,
Browser_ShowNotification,

View file

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

View file

@ -753,6 +753,22 @@
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="macOSGroup">
<property name="title">
<string>macOS Integration</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_9">
<item>
<widget class="QCheckBox" name="macOSAutoFillCheckBox">
<property name="text">
<string>Enable macOS AutoFill Integration</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="generalGroup">
<property name="title">

View file

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

View file

@ -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 @@ public:
MainWindow();
~MainWindow() override;
#ifdef Q_OS_MACOS
AutoFill* autoFill() const { return m_autoFill; }
#endif
QList<DatabaseWidget*> getOpenDatabases();
void restoreConfigState();
void setAllowScreenCapture(bool state);
@ -190,6 +197,9 @@ private:
QPointer<QProgressBar> m_progressBar;
QPointer<QLabel> m_progressBarLabel;
QPointer<QLabel> m_statusBarLabel;
#ifdef Q_OS_MACOS
AutoFill* m_autoFill = nullptr;
#endif
Q_DISABLE_COPY(MainWindow)

View file

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

141
tests/TestAutoFillUtils.cpp Normal file
View file

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

36
tests/TestAutoFillUtils.h Normal file
View file

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