mirror of
https://github.com/keepassxreboot/keepassxc.git
synced 2026-03-11 08:54:48 +00:00
Merge 1656ea2f8b into d9be1b0682
This commit is contained in:
commit
4f182b78cb
22 changed files with 2121 additions and 2 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
45
src/autofill/AutoFill.h
Normal 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
759
src/autofill/AutoFill.mm
Normal 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();
|
||||
}
|
||||
41
src/autofill/AutoFillProviderProtocol.h
Normal file
41
src/autofill/AutoFillProviderProtocol.h
Normal 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
|
||||
99
src/autofill/AutoFillUtils.h
Normal file
99
src/autofill/AutoFillUtils.h
Normal 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
|
||||
34
src/autofill/AutoFillXPCProtocol.h
Normal file
34
src/autofill/AutoFillXPCProtocol.h
Normal 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
|
||||
29
src/autofill/AutoFillXPCService-Info.plist
Normal file
29
src/autofill/AutoFillXPCService-Info.plist
Normal 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>
|
||||
153
src/autofill/AutoFillXPCService.m
Normal file
153
src/autofill/AutoFillXPCService.m
Normal 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;
|
||||
}
|
||||
528
src/autofill/CredentialProviderViewController.mm
Normal file
528
src/autofill/CredentialProviderViewController.mm
Normal 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
|
||||
45
src/autofill/mac/Info.plist.in
Normal file
45
src/autofill/mac/Info.plist.in
Normal 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>
|
||||
14
src/autofill/mac/MacAutoFill.entitlements
Normal file
14
src/autofill/mac/MacAutoFill.entitlements
Normal 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>
|
||||
|
|
@ -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}},
|
||||
|
|
|
|||
|
|
@ -137,6 +137,7 @@ public:
|
|||
Security_EnableCopyOnDoubleClick,
|
||||
Security_QuickUnlock,
|
||||
Security_DatabasePasswordMinimumQuality,
|
||||
Security_macOSAutoFill,
|
||||
|
||||
Browser_Enabled,
|
||||
Browser_ShowNotification,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
141
tests/TestAutoFillUtils.cpp
Normal 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
36
tests/TestAutoFillUtils.h
Normal 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
|
||||
Loading…
Reference in a new issue