diff --git a/share/translations/keepassxc_en.ts b/share/translations/keepassxc_en.ts index f974db170..fd17a022b 100644 --- a/share/translations/keepassxc_en.ts +++ b/share/translations/keepassxc_en.ts @@ -657,10 +657,6 @@ Convenience - - Enable database quick unlock (Touch ID / Windows Hello) - - Lock databases when session is locked or lid is closed @@ -705,6 +701,14 @@ Hide notes in the entry preview panel + + Enable database quick unlock by default + + + + Remember quick unlock after database is closed + + AttachmentWidget @@ -1654,10 +1658,6 @@ Backup database located at %2 Unlock Database - - Cancel - - Unlock @@ -1735,10 +1735,6 @@ To prevent this error from appearing, you must go to "Database Settings / S Cannot use database file as key file - - authenticate to access the database - - Failed to authenticate with Quick Unlock: %1 @@ -1791,6 +1787,14 @@ Are you sure you want to continue with this file?. <a href="#" style="text-decoration: underline">I have a key file</a> + + Reset + + + + Close Database + + Hardware keys found, but no slots are configured. @@ -1799,6 +1803,10 @@ Are you sure you want to continue with this file?. Press ESC again to close this database + + Quick Unlock + + DatabaseSettingWidgetMetaData @@ -9134,46 +9142,10 @@ This option is deprecated, use --set-key-file instead. Passkeys - - AES initialization failed - - - - AES encrypt failed - - - - Failed to store in Linux Keyring - - Polkit returned an error: %1 - - Could not locate key in keyring - - - - Could not read key in keyring - - - - AES decrypt failed - - - - No Polkit authentication agent was available - - - - Polkit authorization failed - - - - No Quick Unlock provider is available - - Failed to init KeePassXC crypto. @@ -9182,10 +9154,6 @@ This option is deprecated, use --set-key-file instead. Failed to encrypt key data. - - Failed to get Windows Hello credential. - - Failed to decrypt key data. @@ -9349,7 +9317,35 @@ This option is deprecated, use --set-key-file instead. - Format to use when exporting. Available choices are 'xml', 'csv' or 'html'. Defaults to 'xml'. + Quick Unlock Pin Entry + + + + Pin setup was canceled. Quick unlock has not been enabled. + + + + Failed to get credentials for quick unlock. + + + + Enter quick unlock pin (%1 of %2 attempts): + + + + Pin entry was canceled. + + + + No Polkit authentication agent was available. + + + + Polkit authorization failed. + + + + Windows Hello setup was canceled or failed. Quick unlock has not been enabled. @@ -9433,6 +9429,34 @@ This option is deprecated, use --set-key-file instead. Confirm Replace Entry References + + Format to use when exporting. Available choices are 'xml', 'csv' or 'html'. Defaults to 'xml'. + + + + Enter a %1–%2 digit pin to use for quick unlock: + + + + Failed to derive key using Argon2 + + + + Too many pin attempts. + + + + No key is stored for this database. + + + + Failed to obtain session key. + + + + Failed to retrieve Windows Hello credential. + + QtIOCompressor diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 6a5eb5d80..68da50d68 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -217,6 +217,7 @@ set(gui_SOURCES gui/wizard/NewDatabaseWizardPageEncryption.cpp gui/wizard/NewDatabaseWizardPageDatabaseKey.cpp quickunlock/QuickUnlockInterface.cpp + quickunlock/PinUnlock.cpp ../share/icons/icons.qrc ../share/wizard/wizard.qrc) @@ -227,40 +228,41 @@ if(APPLE) gui/osutils/macutils/ScreenLockListenerMac.cpp gui/osutils/macutils/AppKitImpl.mm gui/osutils/macutils/AppKit.h - quickunlock/TouchID.mm) - - # TODO: Remove -Wno-error once deprecation warnings have been resolved. - set_source_files_properties(quickunlock/TouchID.mm PROPERTY COMPILE_FLAGS "-Wno-old-style-cast") + quickunlock/TouchID.cpp) endif() if(UNIX AND NOT APPLE) list(APPEND gui_SOURCES gui/osutils/nixutils/ScreenLockListenerDBus.cpp gui/osutils/nixutils/NixUtils.cpp) - if("${CMAKE_SYSTEM}" MATCHES "Linux") - list(APPEND core_SOURCES - quickunlock/Polkit.cpp - quickunlock/PolkitDbusTypes.cpp) - endif() if(WITH_XC_X11) list(APPEND gui_SOURCES gui/osutils/nixutils/X11Funcs.cpp) endif() + + # Polkit is only available on Linux systems + if("${CMAKE_SYSTEM}" MATCHES "Linux") + list(APPEND gui_SOURCES + quickunlock/Polkit.cpp + quickunlock/PolkitDbusTypes.cpp) + + set_source_files_properties( + quickunlock/dbus/org.freedesktop.PolicyKit1.Authority.xml + PROPERTIES + INCLUDE "quickunlock/PolkitDbusTypes.h" + ) + qt5_add_dbus_interface(gui_SOURCES + quickunlock/dbus/org.freedesktop.PolicyKit1.Authority.xml + polkit_dbus + ) + endif() + + # dbus support qt5_add_dbus_adaptor(gui_SOURCES gui/org.keepassxc.KeePassXC.MainWindow.xml gui/MainWindow.h MainWindow) - set_source_files_properties( - quickunlock/dbus/org.freedesktop.PolicyKit1.Authority.xml - PROPERTIES - INCLUDE "quickunlock/PolkitDbusTypes.h" - ) - qt5_add_dbus_interface(core_SOURCES - quickunlock/dbus/org.freedesktop.PolicyKit1.Authority.xml - polkit_dbus - ) - find_library(KEYUTILS_LIBRARIES NAMES keyutils) if(NOT KEYUTILS_LIBRARIES) message(FATAL_ERROR "Could not find libkeyutils") diff --git a/src/core/Config.cpp b/src/core/Config.cpp index 2c2b0bc57..4a9385617 100644 --- a/src/core/Config.cpp +++ b/src/core/Config.cpp @@ -155,7 +155,7 @@ static const QHash configStrings = { {Config::Security_NoConfirmMoveEntryToRecycleBin,{QS("Security/NoConfirmMoveEntryToRecycleBin"), Roaming, true}}, {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_QuickUnlockRemember, {QS("Security/QuickUnlockRemember"), Local, true}}, // Browser {Config::Browser_Enabled, {QS("Browser/Enabled"), Roaming, false}}, diff --git a/src/core/Config.h b/src/core/Config.h index 8f54f9c01..7652caecc 100644 --- a/src/core/Config.h +++ b/src/core/Config.h @@ -136,6 +136,7 @@ public: Security_NoConfirmMoveEntryToRecycleBin, Security_EnableCopyOnDoubleClick, Security_QuickUnlock, + Security_QuickUnlockRemember, Security_DatabasePasswordMinimumQuality, Browser_Enabled, diff --git a/src/crypto/kdf/Argon2Kdf.cpp b/src/crypto/kdf/Argon2Kdf.cpp index f7b028ffe..233463d57 100644 --- a/src/crypto/kdf/Argon2Kdf.cpp +++ b/src/crypto/kdf/Argon2Kdf.cpp @@ -163,6 +163,13 @@ QVariantMap Argon2Kdf::writeParameters() bool Argon2Kdf::transform(const QByteArray& raw, QByteArray& result) const { + // This is a programming error and will result in broken encryption + Q_ASSERT(*raw != *result); + if (*raw == *result) { + qWarning("Argon2Kdf: Input and output buffers must not be the same."); + return false; + } + result.clear(); result.resize(32); // Time Cost, Mem Cost, Threads/Lanes, Password, length, Salt, length, out, length diff --git a/src/gui/ApplicationSettingsWidget.cpp b/src/gui/ApplicationSettingsWidget.cpp index 9f24bbc85..3f1f91cad 100644 --- a/src/gui/ApplicationSettingsWidget.cpp +++ b/src/gui/ApplicationSettingsWidget.cpp @@ -348,8 +348,15 @@ void ApplicationSettingsWidget::loadSettings() m_secUi->hideTotpCheckBox->setChecked(config()->get(Config::Security_HideTotpPreviewPanel).toBool()); m_secUi->hideNotesCheckBox->setChecked(config()->get(Config::Security_HideNotes).toBool()); - m_secUi->quickUnlockCheckBox->setEnabled(getQuickUnlock()->isAvailable()); m_secUi->quickUnlockCheckBox->setChecked(config()->get(Config::Security_QuickUnlock).toBool()); + m_secUi->quickUnlockRememberCheckBox->setChecked(config()->get(Config::Security_QuickUnlockRemember).toBool()); +#ifdef Q_OS_LINUX + // Remembering quick unlock is not supported on Linux + m_secUi->quickUnlockRememberCheckBox->setVisible(false); +#else + // Only show this option if Touch ID or Windows Hello are available for use + m_secUi->quickUnlockRememberCheckBox->setVisible(getQuickUnlock()->isNativeAvailable()); +#endif for (const ExtraPage& page : asConst(m_extraPages)) { page.loadSettings(); @@ -469,9 +476,8 @@ void ApplicationSettingsWidget::saveSettings() config()->set(Config::Security_HideTotpPreviewPanel, m_secUi->hideTotpCheckBox->isChecked()); config()->set(Config::Security_HideNotes, m_secUi->hideNotesCheckBox->isChecked()); - if (m_secUi->quickUnlockCheckBox->isEnabled()) { - config()->set(Config::Security_QuickUnlock, m_secUi->quickUnlockCheckBox->isChecked()); - } + config()->set(Config::Security_QuickUnlock, m_secUi->quickUnlockCheckBox->isChecked()); + config()->set(Config::Security_QuickUnlockRemember, m_secUi->quickUnlockRememberCheckBox->isChecked()); // Security: clear storage if related settings are disabled if (!config()->get(Config::RememberLastDatabases).toBool()) { diff --git a/src/gui/ApplicationSettingsWidgetGeneral.ui b/src/gui/ApplicationSettingsWidgetGeneral.ui index 42ce4acc1..01472e6ba 100644 --- a/src/gui/ApplicationSettingsWidgetGeneral.ui +++ b/src/gui/ApplicationSettingsWidgetGeneral.ui @@ -148,7 +148,7 @@ 40 - 20 + 0 @@ -174,7 +174,7 @@ 30 - 20 + 0 @@ -210,7 +210,7 @@ 30 - 20 + 0 @@ -250,7 +250,7 @@ 30 - 20 + 0 @@ -315,7 +315,7 @@ 40 - 20 + 0 @@ -483,7 +483,7 @@ 30 - 20 + 0 @@ -516,7 +516,7 @@ 40 - 20 + 0 @@ -648,7 +648,7 @@ 30 - 20 + 0 @@ -681,7 +681,7 @@ 30 - 20 + 0 @@ -743,7 +743,7 @@ 40 - 20 + 0 @@ -971,7 +971,7 @@ 30 - 20 + 0 @@ -1019,7 +1019,7 @@ 40 - 20 + 0 @@ -1076,7 +1076,7 @@ 30 - 20 + 0 diff --git a/src/gui/ApplicationSettingsWidgetSecurity.ui b/src/gui/ApplicationSettingsWidgetSecurity.ui index c4cb0e01f..56a6f7343 100644 --- a/src/gui/ApplicationSettingsWidgetSecurity.ui +++ b/src/gui/ApplicationSettingsWidgetSecurity.ui @@ -6,8 +6,8 @@ 0 0 - 364 - 505 + 437 + 529 @@ -138,7 +138,7 @@ 40 - 20 + 0 @@ -168,7 +168,14 @@ - Enable database quick unlock (Touch ID / Windows Hello) + Enable database quick unlock by default + + + + + + + Remember quick unlock after database is closed diff --git a/src/gui/DatabaseOpenDialog.cpp b/src/gui/DatabaseOpenDialog.cpp index 881db4087..70ede8986 100644 --- a/src/gui/DatabaseOpenDialog.cpp +++ b/src/gui/DatabaseOpenDialog.cpp @@ -84,9 +84,8 @@ void DatabaseOpenDialog::showEvent(QShowEvent* event) { QDialog::showEvent(event); QTimer::singleShot(100, this, [this] { - if (m_view->isOnQuickUnlockScreen() && !m_view->unlockingDatabase()) { - m_view->triggerQuickUnlock(); - } + // Automatically trigger quick unlock if it's available + m_view->triggerQuickUnlock(); }); } diff --git a/src/gui/DatabaseOpenWidget.cpp b/src/gui/DatabaseOpenWidget.cpp index 7cf5dcb15..eef4c2899 100644 --- a/src/gui/DatabaseOpenWidget.cpp +++ b/src/gui/DatabaseOpenWidget.cpp @@ -38,14 +38,6 @@ namespace { constexpr int clearFormsDelay = 30000; - - bool isQuickUnlockAvailable() - { - if (config()->get(Config::Security_QuickUnlock).toBool()) { - return getQuickUnlock()->isAvailable(); - } - return false; - } } // namespace DatabaseOpenWidget::DatabaseOpenWidget(QWidget* parent) @@ -68,17 +60,10 @@ DatabaseOpenWidget::DatabaseOpenWidget(QWidget* parent) m_ui->editPassword->setShowPassword(false); }); - QFont font; - font.setPointSize(font.pointSize() + 4); - font.setBold(true); - m_ui->labelHeadline->setFont(font); - - m_ui->quickUnlockButton->setFont(font); - m_ui->quickUnlockButton->setIcon( - icons()->icon("fingerprint", true, palette().color(QPalette::Active, QPalette::HighlightedText))); - m_ui->quickUnlockButton->setIconSize({32, 32}); - - connect(m_ui->buttonBrowseFile, SIGNAL(clicked()), SLOT(browseKeyFile())); + QFont largeFont; + largeFont.setPointSize(largeFont.pointSize() + 4); + largeFont.setBold(true); + m_ui->labelHeadline->setFont(largeFont); auto okBtn = m_ui->buttonBox->button(QDialogButtonBox::Ok); okBtn->setText(tr("Unlock")); @@ -86,16 +71,19 @@ DatabaseOpenWidget::DatabaseOpenWidget(QWidget* parent) connect(m_ui->buttonBox, SIGNAL(accepted()), SLOT(openDatabase())); connect(m_ui->buttonBox, SIGNAL(rejected()), SLOT(reject())); + // Key file components + m_ui->selectKeyFileComponent->setVisible(false); connect(m_ui->addKeyFileLinkLabel, &QLabel::linkActivated, this, &DatabaseOpenWidget::browseKeyFile); + connect(m_ui->buttonBrowseFile, SIGNAL(clicked()), SLOT(browseKeyFile())); connect(m_ui->keyFileLineEdit, &PasswordWidget::textChanged, this, [&](const QString& text) { bool state = !text.isEmpty(); m_ui->addKeyFileLinkLabel->setVisible(!state); m_ui->selectKeyFileComponent->setVisible(state); }); - connect(m_ui->useHardwareKeyCheckBox, &QCheckBox::toggled, m_ui->hardwareKeyCombo, &QComboBox::setEnabled); - m_ui->selectKeyFileComponent->setVisible(false); + // Hardware key components toggleHardwareKeyComponent(false); + connect(m_ui->useHardwareKeyCheckBox, &QCheckBox::toggled, m_ui->hardwareKeyCombo, &QComboBox::setEnabled); QSizePolicy sp = m_ui->hardwareKeyProgress->sizePolicy(); sp.setRetainSizeWhenHidden(true); @@ -127,13 +115,24 @@ DatabaseOpenWidget::DatabaseOpenWidget(QWidget* parent) m_ui->refreshHardwareKeys->setVisible(false); #endif - // QuickUnlock actions + // QuickUnlock components + m_ui->quickUnlockButton->setFont(largeFont); + m_ui->quickUnlockButton->setIcon( + icons()->icon("fingerprint", true, palette().color(QPalette::Active, QPalette::HighlightedText))); + connect(m_ui->quickUnlockButton, &QPushButton::pressed, this, [this] { openDatabase(); }); connect(m_ui->resetQuickUnlockButton, &QPushButton::pressed, this, [this] { resetQuickUnlock(); }); + connect(m_ui->closeQuickUnlockButton, &QPushButton::pressed, this, [this] { reject(); }); m_ui->resetQuickUnlockButton->setShortcut(Qt::Key_Escape); } -DatabaseOpenWidget::~DatabaseOpenWidget() = default; +DatabaseOpenWidget::~DatabaseOpenWidget() +{ + // Reset quick unlock if we are not remembering it + if (!config()->get(Config::Security_QuickUnlockRemember).toBool()) { + resetQuickUnlock(); + } +} void DatabaseOpenWidget::toggleHardwareKeyComponent(bool state) { @@ -189,7 +188,7 @@ bool DatabaseOpenWidget::event(QEvent* event) auto type = event->type(); if (type == QEvent::Show || type == QEvent::WindowActivate) { - if (isOnQuickUnlockScreen() && (m_db.isNull() || !canPerformQuickUnlock())) { + if (isOnQuickUnlockScreen() && !canPerformQuickUnlock()) { resetQuickUnlock(); } toggleQuickUnlockScreen(); @@ -294,6 +293,7 @@ void DatabaseOpenWidget::load(const QString& filename) } toggleQuickUnlockScreen(); + m_ui->enableQuickUnlockCheckBox->setChecked(config()->get(Config::Security_QuickUnlock).toBool()); #ifdef WITH_XC_YUBIKEY // Do initial auto-poll @@ -335,16 +335,12 @@ void DatabaseOpenWidget::enterKey(const QString& pw, const QString& keyFile) m_ui->editPassword->setText(pw); m_ui->keyFileLineEdit->setText(keyFile); - m_blockQuickUnlock = true; + m_ui->enableQuickUnlockCheckBox->setChecked(false); openDatabase(); } void DatabaseOpenWidget::openDatabase() { - // Cache this variable for future use then reset - bool blockQuickUnlock = m_blockQuickUnlock || isOnQuickUnlockScreen(); - m_blockQuickUnlock = false; - setUserInteractionLock(true); m_ui->editPassword->setShowPassword(false); m_ui->messageWidget->hide(); @@ -386,10 +382,13 @@ void DatabaseOpenWidget::openDatabase() } } - // Save Quick Unlock credentials if available - if (!blockQuickUnlock && isQuickUnlockAvailable()) { + // Save Quick Unlock credentials if available and enabled + if (!isOnQuickUnlockScreen() && isQuickUnlockAvailable() && m_ui->enableQuickUnlockCheckBox->isChecked()) { auto keyData = databaseKey->serialize(); - getQuickUnlock()->setKey(m_db->publicUuid(), keyData); + auto qu = getQuickUnlock()->interface(); + if (!qu->setKey(m_db->publicUuid(), keyData) && !qu->errorString().isEmpty()) { + getMainWindow()->displayTabMessage(qu->errorString(), MessageWidget::MessageType::Warning); + } m_ui->messageWidget->hideMessage(); } @@ -434,13 +433,16 @@ QSharedPointer DatabaseOpenWidget::buildDatabaseKey() { auto databaseKey = QSharedPointer::create(); - if (!m_db.isNull() && canPerformQuickUnlock()) { - // try to retrieve the stored password using Windows Hello + if (canPerformQuickUnlock()) { + // try to retrieve the stored password using quick unlock QByteArray keyData; - if (!getQuickUnlock()->getKey(m_db->publicUuid(), keyData)) { - m_ui->messageWidget->showMessage( - tr("Failed to authenticate with Quick Unlock: %1").arg(getQuickUnlock()->errorString()), - MessageWidget::Error); + auto qu = getQuickUnlock()->interface(); + if (!qu->getKey(m_db->publicUuid(), keyData)) { + m_ui->messageWidget->showMessage(tr("Failed to authenticate with Quick Unlock: %1").arg(qu->errorString()), + MessageWidget::Error); + if (!qu->hasKey(m_db->publicUuid())) { + resetQuickUnlock(); + } return {}; } databaseKey->setRawKey(keyData); @@ -627,9 +629,15 @@ void DatabaseOpenWidget::setUserInteractionLock(bool state) m_unlockingDatabase = state; } +bool DatabaseOpenWidget::isQuickUnlockAvailable() const +{ + auto qu = getQuickUnlock()->interface(); + return qu && qu->isAvailable(); +} + bool DatabaseOpenWidget::canPerformQuickUnlock() const { - return !m_db.isNull() && isQuickUnlockAvailable() && getQuickUnlock()->hasKey(m_db->publicUuid()); + return m_db && isQuickUnlockAvailable() && getQuickUnlock()->interface()->hasKey(m_db->publicUuid()); } bool DatabaseOpenWidget::isOnQuickUnlockScreen() const @@ -656,7 +664,7 @@ void DatabaseOpenWidget::toggleQuickUnlockScreen() void DatabaseOpenWidget::triggerQuickUnlock() { - if (isOnQuickUnlockScreen()) { + if (isOnQuickUnlockScreen() && !unlockingDatabase()) { m_ui->quickUnlockButton->click(); } } @@ -668,11 +676,9 @@ void DatabaseOpenWidget::triggerQuickUnlock() */ void DatabaseOpenWidget::resetQuickUnlock() { - if (!isQuickUnlockAvailable()) { - return; - } - if (!m_db.isNull()) { - getQuickUnlock()->reset(m_db->publicUuid()); + auto qu = getQuickUnlock()->interface(); + if (m_db && qu) { + qu->reset(m_db->publicUuid()); } load(m_filename); } diff --git a/src/gui/DatabaseOpenWidget.h b/src/gui/DatabaseOpenWidget.h index d730634b3..70046ecd4 100644 --- a/src/gui/DatabaseOpenWidget.h +++ b/src/gui/DatabaseOpenWidget.h @@ -19,7 +19,6 @@ #ifndef KEEPASSX_DATABASEOPENWIDGET_H #define KEEPASSX_DATABASEOPENWIDGET_H -#include #include #include @@ -46,21 +45,17 @@ class DatabaseOpenWidget : public DialogyWidget public: explicit DatabaseOpenWidget(QWidget* parent = nullptr); ~DatabaseOpenWidget() override; + void load(const QString& filename); QString filename(); + QSharedPointer database(); + void clearForms(); void enterKey(const QString& pw, const QString& keyFile); - QSharedPointer database(); + void triggerQuickUnlock(); bool unlockingDatabase(); void showMessage(const QString& text, MessageWidget::MessageType type, int autoHideTimeout); - // Quick Unlock helper functions - bool canPerformQuickUnlock() const; - bool isOnQuickUnlockScreen() const; - void toggleQuickUnlockScreen(); - void triggerQuickUnlock(); - void resetQuickUnlock(); - signals: void dialogFinished(bool accepted); @@ -85,14 +80,20 @@ private slots: void closeDatabase(); void pollHardwareKey(bool manualTrigger = false, int delay = 0); void hardwareKeyResponse(bool found); + void resetQuickUnlock(); private: + // Quick Unlock helper functions + bool isQuickUnlockAvailable() const; + bool canPerformQuickUnlock() const; + bool isOnQuickUnlockScreen() const; + void toggleQuickUnlockScreen(); + #ifdef WITH_XC_YUBIKEY QPointer m_deviceListener; #endif bool m_pollingHardwareKey = false; bool m_manualHardwareKeyRefresh = false; - bool m_blockQuickUnlock = false; bool m_unlockingDatabase = false; bool m_triedToQuit = false; QTimer m_hideTimer; diff --git a/src/gui/DatabaseOpenWidget.ui b/src/gui/DatabaseOpenWidget.ui index 1ef04a528..e479bcac3 100644 --- a/src/gui/DatabaseOpenWidget.ui +++ b/src/gui/DatabaseOpenWidget.ui @@ -180,7 +180,7 @@ 0 - 10 + 0 @@ -192,7 +192,7 @@ - 2 + 0 @@ -250,13 +250,13 @@ 0 - 0 + 10 0 - 10 + 15 @@ -399,7 +399,7 @@ 40 - 20 + 0 @@ -465,6 +465,48 @@ 5 + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Qt::RightToLeft + + + Quick Unlock + + + true + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 8 + 20 + + + + @@ -511,6 +553,9 @@ + + 0 + @@ -542,17 +587,81 @@ Unlock Database + + + 32 + 32 + + true - - - Cancel + + + Qt::Vertical - + + QSizePolicy::Fixed + + + + 20 + 4 + + + + + + + + 0 + + + + + + 0 + 20 + + + + Reset + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 4 + 20 + + + + + + + + + 0 + 20 + + + + Close Database + + + + @@ -645,8 +754,6 @@ - quickUnlockButton - resetQuickUnlockButton editPassword keyFileLineEdit buttonBrowseFile @@ -654,7 +761,11 @@ hardwareKeyCombo refreshHardwareKeys addKeyFileLinkLabel + enableQuickUnlockCheckBox buttonBox + quickUnlockButton + resetQuickUnlockButton + closeQuickUnlockButton diff --git a/src/gui/DatabaseWidget.cpp b/src/gui/DatabaseWidget.cpp index 2afde49fe..d8290705e 100644 --- a/src/gui/DatabaseWidget.cpp +++ b/src/gui/DatabaseWidget.cpp @@ -1978,8 +1978,6 @@ void DatabaseWidget::closeEvent(QCloseEvent* event) event->ignore(); return; } - - m_databaseOpenWidget->resetQuickUnlock(); event->accept(); } diff --git a/src/gui/dbsettings/DatabaseSettingsWidgetDatabaseKey.cpp b/src/gui/dbsettings/DatabaseSettingsWidgetDatabaseKey.cpp index a74b20ead..7d6081bcc 100644 --- a/src/gui/dbsettings/DatabaseSettingsWidgetDatabaseKey.cpp +++ b/src/gui/dbsettings/DatabaseSettingsWidgetDatabaseKey.cpp @@ -229,7 +229,7 @@ bool DatabaseSettingsWidgetDatabaseKey::saveSettings() m_db->setKey(newKey, true, false, false); - getQuickUnlock()->reset(m_db->publicUuid()); + getQuickUnlock()->interface()->reset(m_db->publicUuid()); emit editFinished(true); if (m_isDirty) { diff --git a/src/gui/osutils/OSUtilsBase.h b/src/gui/osutils/OSUtilsBase.h index 11d739fde..b43278840 100644 --- a/src/gui/osutils/OSUtilsBase.h +++ b/src/gui/osutils/OSUtilsBase.h @@ -72,6 +72,14 @@ public: virtual bool canPreventScreenCapture() const = 0; virtual bool setPreventScreenCapture(QWindow* window, bool allow) const; + /** + * Platform specific secrets storage/handling + */ + virtual bool saveSecret(const QString& key, const QByteArray& secretData) const = 0; + virtual bool getSecret(const QString& key, QByteArray& secretData) const = 0; + virtual bool removeSecret(const QString& key) const = 0; + virtual bool removeAllSecrets() const = 0; + signals: void globalShortcutTriggered(const QString& name, const QString& search = {}); diff --git a/src/gui/osutils/macutils/AppKitImpl.mm b/src/gui/osutils/macutils/AppKitImpl.mm index 43b3f3723..19645dd29 100644 --- a/src/gui/osutils/macutils/AppKitImpl.mm +++ b/src/gui/osutils/macutils/AppKitImpl.mm @@ -17,14 +17,22 @@ */ #import "AppKitImpl.h" +#import "MacUtils.h" + #import #import #import #import +#import +#import +#import +#import #if __clang_major__ >= 13 && MAC_OS_X_VERSION_MIN_REQUIRED >= MAC_OS_VERSION_12_3 #import #endif +#include "config-keepassx.h" + @implementation AppKitImpl - (id) initWithObject:(AppKit*)appkit @@ -340,3 +348,222 @@ void AppKit::configureWindowAndHelpMenus(QMainWindow* window, QMenu* helpMenu) { [static_cast(self) configureWindowAndHelpMenus:window helpMenu:helpMenu]; } + +// Common prefix for saved secrets +static const auto s_touchIdKeyPrefix = QStringLiteral("KeepassXC_Keys_"); + +// Convert macOS error codes to strings +inline std::string StatusToErrorMessage(OSStatus status) +{ + CFStringRef text = SecCopyErrorMessageString(status, NULL); + if (!text) { + return std::to_string(status); + } + + auto msg = CFStringGetCStringPtr(text, kCFStringEncodingUTF8); + std::string result; + if (msg) { + result = msg; + } + CFRelease(text); + return result; +} + +// Report status errors if not successful +inline void LogStatusError(const char *message, OSStatus status) +{ + if (status) { + std::string msg = StatusToErrorMessage(status); + qWarning("%s: %s", message, msg.c_str()); + } +} + +// Create an access control object to govern use of the saved secret +SecAccessControlRef createAccessControl(bool useTouchId) +{ + // We need both runtime and compile time checks here to solve the following problems: + // - Not all flags are available in all OS versions, so we have to check it at compile time + // - Requesting Biometry/TouchID/DevicePassword when no fingerprint sensor is available will result in runtime error + SecAccessControlCreateFlags accessControlFlags = 0; + + // When TouchID is not enrolled and the flag is set, the method call fails with an error. + // We still want to set this flag if TouchID is enrolled but temporarily unavailable due to closed lid + // + // Sometimes, the enrolled-check does not work, LAErrorBiometryNotAvailable is returned instead of LAErrorBiometryNotEnrolled. + // To fallback gracefully, we have to try to save the key a second time without this flag. + + if (useTouchId) { +#if XC_COMPILER_SUPPORT(APPLE_BIOMETRY) + // This is the non-deprecated and preferred flag + accessControlFlags = kSecAccessControlBiometryCurrentSet; +#elif XC_COMPILER_SUPPORT(TOUCH_ID) + accessControlFlags = kSecAccessControlTouchIDCurrentSet; +#endif + } + + // Add support for watch authentication if available +#if XC_COMPILER_SUPPORT(WATCH_UNLOCK) + accessControlFlags = accessControlFlags | kSecAccessControlOr | kSecAccessControlWatch; +#endif + + // Check if password fallback is possible and add that as an option +#if XC_COMPILER_SUPPORT(TOUCH_ID) + if (macUtils()->isAuthPolicyAvailable(MacUtils::AuthPolicy::PasswordFallback)) { + accessControlFlags = accessControlFlags | kSecAccessControlOr | kSecAccessControlDevicePasscode; + } +#endif + + CFErrorRef error = nullptr; + auto sacObject = SecAccessControlCreateWithFlags( + kCFAllocatorDefault, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, accessControlFlags, &error); + + if (!sacObject || error) { + auto e = static_cast(error); + qWarning("MacUtils::saveSecret - Error creating security flags: %s", e.localizedDescription.UTF8String); + return nullptr; + } + return sacObject; +} + +bool MacUtils::saveSecret(const QString& key, const QByteArray& secretData) const +{ + const auto keyName = s_touchIdKeyPrefix + key; + + // Delete any existing entry since macOS does not allow overwrite + if (!removeSecret(key)) { + qWarning("MacUtils::saveSecret - Failed to remove existing secret for key '%s'", qPrintable(key)); + } + + // Add new entry + auto keyBase64 = secretData.toBase64(); + auto keyValueData = CFDataCreateWithBytesNoCopy( + kCFAllocatorDefault, reinterpret_cast(keyBase64.data()), + keyBase64.length(), kCFAllocatorDefault); + + auto attributes = CFDictionaryCreateMutable(nullptr, 0, &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks); + CFDictionarySetValue(attributes, kSecClass, kSecClassGenericPassword); + CFDictionarySetValue(attributes, kSecAttrAccount, static_cast(keyName.toNSString())); + CFDictionarySetValue(attributes, kSecValueData, keyValueData); + CFDictionarySetValue(attributes, kSecAttrSynchronizable, kCFBooleanFalse); + CFDictionarySetValue(attributes, kSecUseAuthenticationUI, kSecUseAuthenticationUIAllow); + // First, attempt with TouchID enabled + CFDictionarySetValue(attributes, kSecAttrAccessControl, createAccessControl(true)); + + auto status = SecItemAdd(attributes, nullptr); + if (status != errSecSuccess) { + qDebug("MacUtils::saveSecret - Failed to save secret with TouchID enabled"); + // Try again without TouchID enabled + CFDictionarySetValue(attributes, kSecAttrAccessControl, createAccessControl(false)); + status = SecItemAdd(attributes, nullptr); + if (status != errSecSuccess) { + qWarning("MacUtils::saveSecret - Failed to save secret to keystore"); + } + } + + CFRelease(keyValueData); + CFRelease(attributes); + + return status == errSecSuccess; +} + +bool MacUtils::getSecret(const QString& key, QByteArray& secretData) const +{ + const auto keyName = s_touchIdKeyPrefix + key; + secretData.clear(); + + auto query = CFDictionaryCreateMutable(nullptr, 0, &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks); + CFDictionarySetValue(query, kSecClass, kSecClassGenericPassword); + CFDictionarySetValue(query, kSecAttrAccount, static_cast(keyName.toNSString())); + CFDictionarySetValue(query, kSecReturnData, kCFBooleanTrue); + + CFTypeRef dataTypeRef = nullptr; + auto status = SecItemCopyMatching(query, &dataTypeRef); + CFRelease(query); + + if (status == errSecUserCanceled) { + // user canceled the authentication, return true with empty key + return true; + } else if (status != errSecSuccess || !dataTypeRef) { + // TODO: Log failure + return false; + } + + auto valueData = static_cast(dataTypeRef); + secretData = QByteArray::fromBase64(QByteArray(reinterpret_cast(CFDataGetBytePtr(valueData)), + CFDataGetLength(valueData))); + CFRelease(dataTypeRef); + + return !secretData.isEmpty(); +} + +bool MacUtils::removeSecret(const QString& key) const +{ + const auto keyName = s_touchIdKeyPrefix + key; + auto query = CFDictionaryCreateMutable(nullptr, 0, &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks); + CFDictionarySetValue(query, kSecClass, kSecClassGenericPassword); + CFDictionarySetValue(query, kSecAttrAccount, static_cast(keyName.toNSString())); + CFDictionarySetValue(query, kSecReturnData, kCFBooleanFalse); + // TODO: Log failure to delete? + SecItemDelete(query); + CFRelease(query); + return true; +} + +bool MacUtils::removeAllSecrets() const +{ + auto query = CFDictionaryCreateMutable(nullptr, 0, &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks); + CFDictionarySetValue(query, kSecClass, kSecClassGenericPassword); + CFDictionarySetValue(query, kSecReturnAttributes, kCFBooleanTrue); + CFDictionarySetValue(query, kSecMatchLimit, kSecMatchLimitAll); + + CFTypeRef result = nullptr; + auto status = SecItemCopyMatching(query, &result); + if (status == errSecSuccess && result) { + for (NSDictionary* item in static_cast(result)) { + NSString* account = item[static_cast(kSecAttrAccount)]; + if (account && [account hasPrefix:s_touchIdKeyPrefix.toNSString()]) { + auto delQuery = CFDictionaryCreateMutable(nullptr, 0, &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks); + CFDictionarySetValue(delQuery, kSecClass, kSecClassGenericPassword); + CFDictionarySetValue(delQuery, kSecAttrAccount, static_cast(account)); + // TODO: Log failure to delete? + SecItemDelete(delQuery); + CFRelease(delQuery); + } + } + CFRelease(result); + } + CFRelease(query); + return true; +} + +bool MacUtils::isAuthPolicyAvailable(AuthPolicy policy) const +{ + LAPolicy policyCode; + switch (policy) { + case AuthPolicy::TouchId: + policyCode = LAPolicyDeviceOwnerAuthenticationWithBiometrics; + break; + case AuthPolicy::Watch: + policyCode = LAPolicyDeviceOwnerAuthenticationWithWatch; + break; + case AuthPolicy::PasswordFallback: + policyCode = LAPolicyDeviceOwnerAuthentication; + break; + default: + return false; + } + + @try { + LAContext *context = [[LAContext alloc] init]; + NSError *error = nil; + bool available = [context canEvaluatePolicy:policyCode error:&error]; + [context release]; + if (error) { + qDebug("MacUtils::isPolicyAvailable - Policy not available: %s", error.localizedDescription.UTF8String); + } + return available; + } @catch (NSException *exception) { + qWarning("MacUtils::isPolicyAvailable - Exception occurred: %s", exception.reason.UTF8String); + return false; + } +} diff --git a/src/gui/osutils/macutils/MacUtils.h b/src/gui/osutils/macutils/MacUtils.h index 403419301..ec60db4a7 100644 --- a/src/gui/osutils/macutils/MacUtils.h +++ b/src/gui/osutils/macutils/MacUtils.h @@ -68,6 +68,21 @@ public: bool canPreventScreenCapture() const override; bool setPreventScreenCapture(QWindow* window, bool prevent) const override; + // Key management API (TouchID) + bool saveSecret(const QString& key, const QByteArray& secretData) const override; + bool getSecret(const QString& key, QByteArray& secretData) const override; + bool removeSecret(const QString& key) const override; + bool removeAllSecrets() const override; + + enum class AuthPolicy + { + TouchId, + Watch, + PasswordFallback + }; + + bool isAuthPolicyAvailable(AuthPolicy policy) const; + signals: void userSwitched(); diff --git a/src/gui/osutils/nixutils/NixUtils.cpp b/src/gui/osutils/nixutils/NixUtils.cpp index 225d6a05e..c1347161e 100644 --- a/src/gui/osutils/nixutils/NixUtils.cpp +++ b/src/gui/osutils/nixutils/NixUtils.cpp @@ -30,6 +30,11 @@ #include #include #include + +extern "C" { +#include +} + #ifdef WITH_XC_X11 #include @@ -411,3 +416,74 @@ quint64 NixUtils::getProcessStartTime() const qDebug() << "nixutils: failed to find ')' in " << processStatPath; return 0; } + +namespace +{ + key_serial_t getKeyring() + { + auto keyring = keyctl_get_persistent(-1, KEY_SPEC_PROCESS_KEYRING); + if (keyring == -1) { + // Return the non-persistent keyring as a fallback + qWarning("nixutils: failed to get persistent keyring: %s", strerror(errno)); + keyring = KEY_SPEC_PROCESS_KEYRING; + } + return keyring; + } +} // namespace + +bool NixUtils::saveSecret(const QString& key, const QByteArray& secretData) const +{ + auto keyserial = + add_key("user", key.toStdString().c_str(), secretData.constData(), secretData.size(), getKeyring()); + if (keyserial < 0) { + qWarning("nixutils: failed to save secret: %s", strerror(errno)); + return false; + } + // Only allow this process to read/write this key + keyctl_setperm(keyserial, KEY_POS_ALL); + + return true; +} + +bool NixUtils::getSecret(const QString& key, QByteArray& secretData) const +{ + secretData.clear(); + + auto keyserial = request_key("user", key.toStdString().c_str(), nullptr, getKeyring()); + if (keyserial < 0) { + qWarning("nixutils: failed to find secret: %s", strerror(errno)); + return false; + } + + secretData.resize(512); + auto size = keyctl_read(keyserial, secretData.data(), secretData.size()); + if (size == -1) { + qWarning("nixutils: failed to read secret: %s", strerror(errno)); + return false; + } + + secretData.resize(size); + return true; +} + +bool NixUtils::removeSecret(const QString& key) const +{ + auto keyserial = request_key("user", key.toStdString().c_str(), nullptr, getKeyring()); + if (keyserial < 0) { + qWarning("nixutils: failed to find secret: %s", strerror(errno)); + return false; + } + + if (keyctl_unlink(keyserial, getKeyring()) < 0) { + qWarning("nixutils: failed to remove secret: %s", strerror(errno)); + return false; + } + + return true; +} + +bool NixUtils::removeAllSecrets() const +{ + // NixUtils does not support clearing all keys + return false; +} diff --git a/src/gui/osutils/nixutils/NixUtils.h b/src/gui/osutils/nixutils/NixUtils.h index 9be835ff9..bbf4713e1 100644 --- a/src/gui/osutils/nixutils/NixUtils.h +++ b/src/gui/osutils/nixutils/NixUtils.h @@ -52,6 +52,11 @@ public: quint64 getProcessStartTime() const; + bool saveSecret(const QString& key, const QByteArray& secretData) const override; + bool getSecret(const QString& key, QByteArray& secretData) const override; + bool removeSecret(const QString& key) const override; + bool removeAllSecrets() const override; + private slots: void handleColorSchemeRead(QDBusVariant value); void handleColorSchemeChanged(QString ns, QString key, QDBusVariant value); diff --git a/src/gui/osutils/winutils/WinUtils.cpp b/src/gui/osutils/winutils/WinUtils.cpp index a15976932..6d242963c 100644 --- a/src/gui/osutils/winutils/WinUtils.cpp +++ b/src/gui/osutils/winutils/WinUtils.cpp @@ -20,11 +20,24 @@ #include #include #include +#include #include #include +#include +#include +#include #undef MessageBox +using namespace winrt; +using namespace Windows::Foundation::Collections; +using namespace Windows::Security::Credentials; + +namespace +{ + const std::wstring s_winKeyStoreName{L"keepassxc"}; +} + QPointer WinUtils::m_instance = nullptr; WinUtils* WinUtils::instance() @@ -361,3 +374,59 @@ DWORD WinUtils::qtToNativeModifiers(Qt::KeyboardModifiers modifiers) return nativeModifiers; } + +bool WinUtils::saveSecret(const QString& key, const QByteArray& secretData) const +{ + try { + auto vault = PasswordVault(); + vault.Add({s_winKeyStoreName, + winrt::hstring(key.toStdWString()), + winrt::to_hstring(secretData.toBase64().toStdString())}); + return true; + } catch (winrt::hresult_error const&) { + qWarning("WinUtils - Failed to add key to password vault"); + return false; + } +} + +bool WinUtils::getSecret(const QString& key, QByteArray& secretData) const +{ + secretData.clear(); + try { + auto vault = PasswordVault(); + auto credential = vault.Retrieve(s_winKeyStoreName, winrt::hstring(key.toStdWString())); + secretData = QByteArray::fromBase64(QByteArray::fromStdString(winrt::to_string(credential.Password()))); + } catch (winrt::hresult_error const&) { + qWarning("WinUtils - Failed to retrieve key from password vault"); + return false; + } + return !secretData.isEmpty(); +} + +bool WinUtils::removeSecret(const QString& key) const +{ + try { + auto vault = PasswordVault(); + vault.Remove({s_winKeyStoreName, winrt::hstring(key.toStdWString()), L"nodata"}); + return true; + } catch (winrt::hresult_error const&) { + qWarning("WinUtils - Failed to clear key from password vault"); + return false; + } +} + +bool WinUtils::removeAllSecrets() const +{ + auto vault = PasswordVault(); + auto credentials = vault.FindAllByResource(s_winKeyStoreName); + bool allSuccess = true; + for (const auto& credential : credentials) { + try { + vault.Remove(credential); + } catch (winrt::hresult_error const&) { + qWarning("WinUtils - Failed to clear key from password vault"); + allSuccess = false; + } + } + return allSuccess; +} diff --git a/src/gui/osutils/winutils/WinUtils.h b/src/gui/osutils/winutils/WinUtils.h index 9278c9d60..680d19196 100644 --- a/src/gui/osutils/winutils/WinUtils.h +++ b/src/gui/osutils/winutils/WinUtils.h @@ -61,6 +61,11 @@ public: bool canPreventScreenCapture() const override; bool setPreventScreenCapture(QWindow* window, bool prevent) const override; + bool saveSecret(const QString& key, const QByteArray& secretData) const override; + bool getSecret(const QString& key, QByteArray& secretData) const override; + bool removeSecret(const QString& key) const override; + bool removeAllSecrets() const override; + protected: explicit WinUtils(QObject* parent = nullptr); ~WinUtils() override = default; diff --git a/src/quickunlock/PinUnlock.cpp b/src/quickunlock/PinUnlock.cpp new file mode 100644 index 000000000..d4ca6ca8d --- /dev/null +++ b/src/quickunlock/PinUnlock.cpp @@ -0,0 +1,207 @@ +/* + * Copyright (C) 2025 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "PinUnlock.h" + +#include "crypto/CryptoHash.h" +#include "crypto/Random.h" +#include "crypto/SymmetricCipher.h" +#include "crypto/kdf/Argon2Kdf.h" +#include "gui/osutils/OSUtils.h" + +#include +#include + +const int PinUnlock::MIN_PIN_LENGTH = 6; +const int PinUnlock::MAX_PIN_LENGTH = 10; +const int PinUnlock::MAX_PIN_ATTEMPTS = 3; + +bool PinUnlock::isAvailable() const +{ + return true; +} + +bool PinUnlock::promptPin(int attempt, QByteArray& sessionKey) +{ + QString pin; + + if (attempt == 0) { + // Loop until a valid pin has been entered or canceled + QRegularExpression pinRegex("^\\d+$"); + while (true) { + bool ok = false; + pin = QInputDialog::getText( + nullptr, + QObject::tr("Quick Unlock Pin Entry"), + QObject::tr("Enter a %1–%2 digit pin to use for quick unlock:").arg(MIN_PIN_LENGTH).arg(MAX_PIN_LENGTH), + QLineEdit::Password, + {}, + &ok); + + if (!ok) { + m_error = QObject::tr("Pin setup was canceled. Quick unlock has not been enabled."); + return false; + } + + // Validate pin criteria + if (pin.length() >= MIN_PIN_LENGTH && pin.length() <= MAX_PIN_LENGTH && pinRegex.match(pin).hasMatch()) { + // Pin is valid, move to hashing + break; + } + } + } else { + bool ok = false; + pin = QInputDialog::getText( + nullptr, + QObject::tr("Quick Unlock Pin Entry"), + QObject::tr("Enter quick unlock pin (%1 of %2 attempts):").arg(attempt).arg(MAX_PIN_ATTEMPTS), + QLineEdit::Password, + {}, + &ok); + + if (!ok) { + // User canceled the pin entry dialog, record pin attempts + m_error = QObject::tr("Pin entry was canceled."); + return false; + } + } + + // Hash the pin then run it through Argon2 to derive the encryption key + sessionKey.fill('\0', 32); + Argon2Kdf kdf(Argon2Kdf::Type::Argon2id); + CryptoHash hash(CryptoHash::Sha256); + hash.addData(pin.toLatin1()); + if (!kdf.transform(hash.result(), sessionKey)) { + m_error = QObject::tr("Failed to derive key using Argon2"); + return false; + } + + return true; +} + +bool PinUnlock::setKey(const QUuid& dbUuid, const QByteArray& data) +{ + QByteArray key; + if (!promptPin(0, key)) { + // Pin entry was canceled or failed, error set by promptPin + return false; + } + + // Generate a random IV + const auto iv = Random::instance()->randomArray(SymmetricCipher::defaultIvSize(SymmetricCipher::Aes256_GCM)); + + // Encrypt the data using AES-256-GCM + SymmetricCipher cipher; + if (!cipher.init(SymmetricCipher::Aes256_GCM, SymmetricCipher::Encrypt, key, iv)) { + m_error = QObject::tr("Failed to init KeePassXC crypto."); + return false; + } + QByteArray encrypted = data; + if (!cipher.finish(encrypted)) { + m_error = QObject::tr("Failed to encrypt key data."); + return false; + } + + // Store the encrypted data + saveKey(dbUuid, encrypted.prepend(iv)); + return true; +} + +bool PinUnlock::getKey(const QUuid& dbUuid, QByteArray& data) +{ + data.clear(); + bool hasSecret = m_encryptedKeys.contains(dbUuid); + if (!hasSecret) { + // Check if the OS has a secret stored for this database UUID + QByteArray tmp; + if (osUtils->getSecret(dbUuid.toString(), tmp)) { + // Cache the secret in memory + m_encryptedKeys.insert(dbUuid, qMakePair(1, tmp)); + } else { + m_error = QObject::tr("Failed to get credentials for quick unlock."); + return false; + } + } + + // Restrict pin attempts per database + const auto& pairData = m_encryptedKeys.value(dbUuid); + for (int pinAttempts = pairData.first; pinAttempts <= MAX_PIN_ATTEMPTS; ++pinAttempts) { + QByteArray key; + if (!promptPin(pinAttempts, key)) { + // Pin entry was canceled or failed, error set by promptPin + m_encryptedKeys.insert(dbUuid, qMakePair(pinAttempts, pairData.second)); + return false; + } + + // Read the previously used challenge and encrypted data + const auto& ivSize = SymmetricCipher::defaultIvSize(SymmetricCipher::Aes256_GCM); + const auto& iv = pairData.second.left(ivSize); + + // Decrypt the data using the generated key and IV from above + SymmetricCipher cipher; + if (!cipher.init(SymmetricCipher::Aes256_GCM, SymmetricCipher::Decrypt, key, iv)) { + m_error = QObject::tr("Failed to init KeePassXC crypto."); + return false; + } + + // Attempt to decrypt the key data + data = pairData.second.mid(ivSize); + if (cipher.finish(data)) { + // Decryption succeeded, reset the pin attempts + m_encryptedKeys.insert(dbUuid, qMakePair(1, pairData.second)); + return true; + } + } + + data.clear(); + m_error = QObject::tr("Too many pin attempts."); + reset(dbUuid); + return false; +} + +void PinUnlock::saveKey(const QUuid& dbUuid, const QByteArray& data) +{ + // Save the key to the OS secret store + if (!osUtils->saveSecret(dbUuid.toString(), data)) { + qWarning("PinUnlock - Failed to save quick unlock credentials."); + } + // Store the encrypted key in memory + m_encryptedKeys.insert(dbUuid, qMakePair(1, data)); +} + +bool PinUnlock::hasKey(const QUuid& dbUuid) const +{ + bool hasSecret = m_encryptedKeys.contains(dbUuid); + if (!hasSecret) { + // Check if the OS has a secret stored for this database UUID + QByteArray tmp; + hasSecret = osUtils->getSecret(dbUuid.toString(), tmp); + } + return hasSecret; +} + +void PinUnlock::reset(const QUuid& dbUuid) +{ + m_encryptedKeys.remove(dbUuid); + osUtils->removeSecret(dbUuid.toString()); +} + +void PinUnlock::reset() +{ + m_encryptedKeys.clear(); + osUtils->removeAllSecrets(); +} diff --git a/src/quickunlock/PinUnlock.h b/src/quickunlock/PinUnlock.h new file mode 100644 index 000000000..081b74405 --- /dev/null +++ b/src/quickunlock/PinUnlock.h @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2025 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEEPASSXC_PINUNLOCK_H +#define KEEPASSXC_PINUNLOCK_H + +#include "QuickUnlockInterface.h" + +#include + +class PinUnlock : public QuickUnlockInterface +{ +public: + PinUnlock() = default; + + bool isAvailable() const override; + + bool setKey(const QUuid& dbUuid, const QByteArray& key) override; + bool getKey(const QUuid& dbUuid, QByteArray& key) override; + bool hasKey(const QUuid& dbUuid) const override; + + void reset(const QUuid& dbUuid) override; + void reset() override; + + static const int MIN_PIN_LENGTH; + static const int MAX_PIN_LENGTH; + static const int MAX_PIN_ATTEMPTS; + +protected: + bool promptPin(int attempt, QByteArray& sessionKey); + +private: + void saveKey(const QUuid& dbUuid, const QByteArray& key); + + QHash> m_encryptedKeys; + + Q_DISABLE_COPY(PinUnlock) +}; + +#endif // KEEPASSXC_PINUNLOCK_H diff --git a/src/quickunlock/Polkit.cpp b/src/quickunlock/Polkit.cpp index d73a7c71b..d22f785b2 100644 --- a/src/quickunlock/Polkit.cpp +++ b/src/quickunlock/Polkit.cpp @@ -1,5 +1,5 @@ /* - * Copyright (C) 2023 KeePassXC Team + * Copyright (C) 2025 KeePassXC Team * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -23,8 +23,8 @@ #include "gui/osutils/nixutils/NixUtils.h" #include -#include #include + #include #include @@ -35,19 +35,11 @@ extern "C" { const QString polkit_service = "org.freedesktop.PolicyKit1"; const QString polkit_object = "/org/freedesktop/PolicyKit1/Authority"; -namespace -{ - QString getKeyName(const QUuid& dbUuid) - { - static const QString keyPrefix = "keepassxc_polkit_keys_"; - return keyPrefix + dbUuid.toString(); - } -} // namespace - Polkit::Polkit() { PolkitSubject::registerMetaType(); PolkitAuthorizationResults::registerMetaType(); + PolkitActionDescription::registerMetaType(); /* Note we explicitly use our own dbus path here, as the ::systemBus() method could be overridden through an environment variable to return an alternative bus path. This bus could have an application @@ -61,18 +53,34 @@ Polkit::Polkit() m_available = bus.isConnected(); if (!m_available) { - qDebug() << "polkit: Failed to connect to system dbus (this may be due to a non-standard dbus path)"; + qWarning() << "polkit: Failed to connect to system dbus (this may be due to a non-standard dbus path)"; return; } m_available = bus.interface()->isServiceRegistered(polkit_service); if (!m_available) { - qDebug() << "polkit: Polkit is not registered on dbus"; + qWarning() << "polkit: Polkit is not registered on dbus"; return; } + // Initiate the Polkit dbus interface m_polkit.reset(new org::freedesktop::PolicyKit1::Authority(polkit_service, polkit_object, bus)); + + // Reset available state and check Polkit registered actions for KeePassXC + m_available = false; + auto kpxcAction = QStringLiteral("org.keepassxc.KeePassXC.unlockDatabase"); + auto actions = m_polkit->EnumerateActions(""); + for (const auto& action : actions.value()) { + if (action.actionId == kpxcAction) { + m_available = true; + break; + } + } + + if (!m_available) { + qWarning() << "polkit: KeePassXC Polkit action is not installed"; + } } Polkit::~Polkit() @@ -81,7 +89,8 @@ Polkit::~Polkit() void Polkit::reset(const QUuid& dbUuid) { - m_encryptedMasterKeys.remove(dbUuid); + m_sessionKeys.remove(dbUuid); + nixUtils()->removeSecret(dbUuid.toString()); } bool Polkit::isAvailable() const @@ -89,67 +98,100 @@ bool Polkit::isAvailable() const return m_available; } -QString Polkit::errorString() const -{ - return m_error; -} - void Polkit::reset() { - m_encryptedMasterKeys.clear(); + m_sessionKeys.clear(); + nixUtils()->removeAllSecrets(); } -bool Polkit::setKey(const QUuid& dbUuid, const QByteArray& key) +bool Polkit::setKey(const QUuid& dbUuid, const QByteArray& data) { reset(dbUuid); - // Generate a random iv/key pair to encrypt the master password with - QByteArray randomKey = randomGen()->randomArray(SymmetricCipher::keySize(SymmetricCipher::Aes256_GCM)); - QByteArray randomIV = randomGen()->randomArray(SymmetricCipher::defaultIvSize(SymmetricCipher::Aes256_GCM)); - QByteArray keychainKeyValue = randomKey + randomIV; + // Prompt for a pin to use as session key + QByteArray key; + if (!promptPin(0, key)) { + return false; + } + + auto iv = randomGen()->randomArray(SymmetricCipher::defaultIvSize(SymmetricCipher::Aes256_GCM)); SymmetricCipher aes256Encrypt; - if (!aes256Encrypt.init(SymmetricCipher::Aes256_GCM, SymmetricCipher::Encrypt, randomKey, randomIV)) { - m_error = QObject::tr("AES initialization failed"); + if (!aes256Encrypt.init(SymmetricCipher::Aes256_GCM, SymmetricCipher::Encrypt, key, iv)) { + m_error = QObject::tr("Failed to init KeePassXC crypto."); return false; } - // Encrypt the master password - QByteArray encryptedMasterKey = key; - if (!aes256Encrypt.finish(encryptedMasterKey)) { - m_error = QObject::tr("AES encrypt failed"); - qDebug() << "polkit aes encrypt failed: " << aes256Encrypt.errorString(); + // Encrypt the database key + QByteArray encrypted = data; + if (!aes256Encrypt.finish(encrypted)) { + m_error = QObject::tr("Failed to encrypt key data."); return false; } - // Add the iv/key pair into the linux keyring - key_serial_t key_serial = add_key("user", - getKeyName(dbUuid).toStdString().c_str(), - keychainKeyValue.constData(), - keychainKeyValue.size(), - KEY_SPEC_PROCESS_KEYRING); - if (key_serial < 0) { - m_error = QObject::tr("Failed to store in Linux Keyring"); - qDebug() << "polkit keyring failed to store: " << errno; - return false; - } + // Store the session key and save the encrypted master key to the keyring + m_sessionKeys.insert(dbUuid, key); + nixUtils()->saveSecret(dbUuid.toString(), encrypted.prepend(iv)); - // Scrub the keys from ram - Botan::secure_scrub_memory(randomKey.data(), randomKey.size()); - Botan::secure_scrub_memory(randomIV.data(), randomIV.size()); - Botan::secure_scrub_memory(keychainKeyValue.data(), keychainKeyValue.size()); - - // Store encrypted master password and return - m_encryptedMasterKeys.insert(dbUuid, encryptedMasterKey); return true; } -bool Polkit::getKey(const QUuid& dbUuid, QByteArray& key) +bool Polkit::getKey(const QUuid& dbUuid, QByteArray& data) { - if (!m_polkit || !hasKey(dbUuid)) { + if (!m_available || !hasKey(dbUuid)) { + m_error = QObject::tr("No key is stored for this database."); return false; } + QByteArray key; + for (int pinAttempts = 1; pinAttempts <= MAX_PIN_ATTEMPTS; ++pinAttempts) { + if (!m_sessionKeys.contains(dbUuid)) { + // Request pin to obtain a session key + if (!promptPin(pinAttempts, key)) { + m_error = QObject::tr("Failed to obtain session key."); + return false; + } + } else { + // We already have the session key, prompt using polkit to authorize use + if (!promptPolkit()) { + // Error set in promptPolkit call + return false; + } + key = m_sessionKeys.value(dbUuid); + } + + // Retrieve the encrypted master key from the OS secret store + QByteArray encData; + if (!nixUtils()->getSecret(dbUuid.toString(), encData)) { + m_error = QObject::tr("Failed to get credentials for quick unlock."); + return false; + } + + const auto& ivSize = SymmetricCipher::defaultIvSize(SymmetricCipher::Aes256_GCM); + const auto& iv = encData.left(ivSize); + + // Decrypt the data using the generated key and IV from above + SymmetricCipher cipher; + if (!cipher.init(SymmetricCipher::Aes256_GCM, SymmetricCipher::Decrypt, key, iv)) { + m_error = QObject::tr("Failed to init KeePassXC crypto."); + return false; + } + + // Attempt to decrypt the key data + data = encData.mid(ivSize); + if (cipher.finish(data)) { + // Decryption succeeded, store the session key used + m_sessionKeys.insert(dbUuid, key); + return true; + } + } + + m_error = QObject::tr("Too many pin attempts."); + return false; +} + +bool Polkit::promptPolkit() +{ PolkitSubject subject; subject.kind = "unix-process"; subject.details.insert("pid", static_cast(QCoreApplication::applicationPid())); @@ -170,78 +212,26 @@ bool Polkit::getKey(const QUuid& dbUuid, QByteArray& key) if (result.isError()) { auto msg = result.error().message(); m_error = QObject::tr("Polkit returned an error: %1").arg(msg); - qDebug() << "polkit returned an error: " << msg; return false; } PolkitAuthorizationResults authResult = result.value(); if (authResult.is_authorized) { - QByteArray encryptedMasterKey = m_encryptedMasterKeys.value(dbUuid); - key_serial_t keySerial = - find_key_by_type_and_desc("user", getKeyName(dbUuid).toStdString().c_str(), KEY_SPEC_PROCESS_KEYRING); - - if (keySerial == -1) { - m_error = QObject::tr("Could not locate key in keyring"); - qDebug() << "polkit keyring failed to find: " << errno; - return false; - } - - void* keychainBuffer; - long keychainDataSize = keyctl_read_alloc(keySerial, &keychainBuffer); - - if (keychainDataSize == -1) { - m_error = QObject::tr("Could not read key in keyring"); - qDebug() << "polkit keyring failed to read: " << errno; - return false; - } - - QByteArray keychainBytes(static_cast(keychainBuffer), keychainDataSize); - - Botan::secure_scrub_memory(keychainBuffer, keychainDataSize); - free(keychainBuffer); - - QByteArray keychainKey = keychainBytes.left(SymmetricCipher::keySize(SymmetricCipher::Aes256_GCM)); - QByteArray keychainIv = keychainBytes.right(SymmetricCipher::defaultIvSize(SymmetricCipher::Aes256_GCM)); - - SymmetricCipher aes256Decrypt; - if (!aes256Decrypt.init(SymmetricCipher::Aes256_GCM, SymmetricCipher::Decrypt, keychainKey, keychainIv)) { - m_error = QObject::tr("AES initialization failed"); - qDebug() << "polkit aes init failed"; - return false; - } - - key = encryptedMasterKey; - if (!aes256Decrypt.finish(key)) { - key.clear(); - m_error = QObject::tr("AES decrypt failed"); - qDebug() << "polkit aes decrypt failed: " << aes256Decrypt.errorString(); - return false; - } - - // Scrub the keys from ram - Botan::secure_scrub_memory(keychainKey.data(), keychainKey.size()); - Botan::secure_scrub_memory(keychainIv.data(), keychainIv.size()); - Botan::secure_scrub_memory(keychainBytes.data(), keychainBytes.size()); - Botan::secure_scrub_memory(encryptedMasterKey.data(), encryptedMasterKey.size()); - return true; } // Failed to authenticate if (authResult.is_challenge) { - m_error = QObject::tr("No Polkit authentication agent was available"); + m_error = QObject::tr("No Polkit authentication agent was available."); } else { - m_error = QObject::tr("Polkit authorization failed"); + m_error = QObject::tr("Polkit authorization failed."); } - return false; } bool Polkit::hasKey(const QUuid& dbUuid) const { - if (!m_encryptedMasterKeys.contains(dbUuid)) { - return false; - } - - return find_key_by_type_and_desc("user", getKeyName(dbUuid).toStdString().c_str(), KEY_SPEC_PROCESS_KEYRING) != -1; + // Check if the OS has a secret stored for this database UUID + QByteArray tmp; + return nixUtils()->getSecret(dbUuid.toString(), tmp); } diff --git a/src/quickunlock/Polkit.h b/src/quickunlock/Polkit.h index 7dfc2db7b..f3033e9c4 100644 --- a/src/quickunlock/Polkit.h +++ b/src/quickunlock/Polkit.h @@ -1,5 +1,5 @@ /* - * Copyright (C) 2023 KeePassXC Team + * Copyright (C) 2025 KeePassXC Team * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -15,36 +15,34 @@ * along with this program. If not, see . */ -#ifndef KEEPASSX_POLKIT_H -#define KEEPASSX_POLKIT_H +#pragma once -#include "QuickUnlockInterface.h" +#include "PinUnlock.h" #include "polkit_dbus.h" + #include #include -class Polkit : public QuickUnlockInterface +class Polkit : public PinUnlock { public: Polkit(); ~Polkit() override; bool isAvailable() const override; - QString errorString() const override; - bool setKey(const QUuid& dbUuid, const QByteArray& key) override; - bool getKey(const QUuid& dbUuid, QByteArray& key) override; + bool setKey(const QUuid& dbUuid, const QByteArray& data) override; + bool getKey(const QUuid& dbUuid, QByteArray& data) override; bool hasKey(const QUuid& dbUuid) const override; void reset(const QUuid& dbUuid) override; void reset() override; private: + bool promptPolkit(); + bool m_available; - QString m_error; - QHash m_encryptedMasterKeys; + QHash m_sessionKeys; QScopedPointer m_polkit; }; - -#endif // KEEPASSX_POLKIT_H diff --git a/src/quickunlock/PolkitDbusTypes.cpp b/src/quickunlock/PolkitDbusTypes.cpp index a4305dc44..618b3c011 100644 --- a/src/quickunlock/PolkitDbusTypes.cpp +++ b/src/quickunlock/PolkitDbusTypes.cpp @@ -1,3 +1,20 @@ +/* + * Copyright (C) 2025 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + #include "PolkitDbusTypes.h" void PolkitSubject::registerMetaType() @@ -43,3 +60,32 @@ const QDBusArgument& operator>>(const QDBusArgument& argument, PolkitAuthorizati argument.endStructure(); return argument; } + +void PolkitActionDescription::registerMetaType() +{ + qRegisterMetaType("PolkitActionDescription"); + qDBusRegisterMetaType(); + + qRegisterMetaType("PolkitActionDescriptionList"); + qDBusRegisterMetaType(); +} + +QDBusArgument& operator<<(QDBusArgument& argument, const PolkitActionDescription& action) +{ + argument.beginStructure(); + argument << action.actionId << action.description << action.message << action.vendorName << action.vendorUrl + << action.iconName << action.implicitAny << action.implicitInactive << action.implicitActive + << action.annotations; + argument.endStructure(); + return argument; +} + +const QDBusArgument& operator>>(const QDBusArgument& argument, PolkitActionDescription& action) +{ + argument.beginStructure(); + argument >> action.actionId >> action.description >> action.message >> action.vendorName >> action.vendorUrl + >> action.iconName >> action.implicitAny >> action.implicitInactive >> action.implicitActive + >> action.annotations; + argument.endStructure(); + return argument; +} diff --git a/src/quickunlock/PolkitDbusTypes.h b/src/quickunlock/PolkitDbusTypes.h index 83eb23889..8fb6daa31 100644 --- a/src/quickunlock/PolkitDbusTypes.h +++ b/src/quickunlock/PolkitDbusTypes.h @@ -1,5 +1,21 @@ -#ifndef KEEPASSX_POLKITDBUSTYPES_H -#define KEEPASSX_POLKITDBUSTYPES_H +/* + * Copyright (C) 2025 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once #include @@ -30,7 +46,30 @@ public: friend const QDBusArgument& operator>>(const QDBusArgument& argument, PolkitAuthorizationResults& subject); }; +class PolkitActionDescription +{ +public: + QString actionId; + QString description; + QString message; + QString vendorName; + QString vendorUrl; + QString iconName; + uint implicitAny; + uint implicitInactive; + uint implicitActive; + QMap annotations; + + static void registerMetaType(); + + friend QDBusArgument& operator<<(QDBusArgument& argument, const PolkitActionDescription& action); + + friend const QDBusArgument& operator>>(const QDBusArgument& argument, PolkitActionDescription& action); +}; + +typedef QList PolkitActionDescriptionList; + Q_DECLARE_METATYPE(PolkitSubject); Q_DECLARE_METATYPE(PolkitAuthorizationResults); - -#endif // KEEPASSX_POLKITDBUSTYPES_H +Q_DECLARE_METATYPE(PolkitActionDescription); +Q_DECLARE_METATYPE(PolkitActionDescriptionList); diff --git a/src/quickunlock/QuickUnlockInterface.cpp b/src/quickunlock/QuickUnlockInterface.cpp index 0e24736e8..bd365ed28 100644 --- a/src/quickunlock/QuickUnlockInterface.cpp +++ b/src/quickunlock/QuickUnlockInterface.cpp @@ -16,66 +16,55 @@ */ #include "QuickUnlockInterface.h" +#include "PinUnlock.h" + #include #if defined(Q_OS_MACOS) #include "TouchID.h" -#define QUICKUNLOCK_IMPLEMENTATION TouchID #elif defined(Q_CC_MSVC) #include "WindowsHello.h" -#define QUICKUNLOCK_IMPLEMENTATION WindowsHello #elif defined(Q_OS_LINUX) #include "Polkit.h" -#define QUICKUNLOCK_IMPLEMENTATION Polkit -#else -#define QUICKUNLOCK_IMPLEMENTATION NoQuickUnlock #endif -QUICKUNLOCK_IMPLEMENTATION* quickUnlockInstance = {nullptr}; +QuickUnlockManager* g_quickUnlockManager = nullptr; -QuickUnlockInterface* getQuickUnlock() +QuickUnlockManager* getQuickUnlock() { - if (!quickUnlockInstance) { - quickUnlockInstance = new QUICKUNLOCK_IMPLEMENTATION(); + if (!g_quickUnlockManager) { + g_quickUnlockManager = new QuickUnlockManager(); } - return quickUnlockInstance; + return g_quickUnlockManager; } -bool NoQuickUnlock::isAvailable() const +QuickUnlockManager::QuickUnlockManager() { - return false; + // Create the native interface based on the platform +#if defined(Q_OS_MACOS) + m_nativeInterface.reset(new TouchID()); +#elif defined(Q_CC_MSVC) + m_nativeInterface.reset(new WindowsHello()); +#elif defined(Q_OS_LINUX) + m_nativeInterface.reset(new Polkit()); +#endif + // Always create the fallback interface + m_fallbackInterface.reset(new PinUnlock()); } -QString NoQuickUnlock::errorString() const -{ - return QObject::tr("No Quick Unlock provider is available"); -} - -void NoQuickUnlock::reset() +QuickUnlockManager::~QuickUnlockManager() { } -bool NoQuickUnlock::setKey(const QUuid& dbUuid, const QByteArray& key) +QSharedPointer QuickUnlockManager::interface() const { - Q_UNUSED(dbUuid) - Q_UNUSED(key) - return false; + if (isNativeAvailable()) { + return m_nativeInterface; + } + return m_fallbackInterface; } -bool NoQuickUnlock::getKey(const QUuid& dbUuid, QByteArray& key) +bool QuickUnlockManager::isNativeAvailable() const { - Q_UNUSED(dbUuid) - Q_UNUSED(key) - return false; -} - -bool NoQuickUnlock::hasKey(const QUuid& dbUuid) const -{ - Q_UNUSED(dbUuid) - return false; -} - -void NoQuickUnlock::reset(const QUuid& dbUuid) -{ - Q_UNUSED(dbUuid) + return m_nativeInterface && m_nativeInterface->isAvailable(); } diff --git a/src/quickunlock/QuickUnlockInterface.h b/src/quickunlock/QuickUnlockInterface.h index 54aeb8a62..8ca09d47e 100644 --- a/src/quickunlock/QuickUnlockInterface.h +++ b/src/quickunlock/QuickUnlockInterface.h @@ -18,6 +18,7 @@ #ifndef KEEPASSXC_QUICKUNLOCKINTERFACE_H #define KEEPASSXC_QUICKUNLOCKINTERFACE_H +#include #include class QuickUnlockInterface @@ -29,7 +30,6 @@ public: virtual ~QuickUnlockInterface() = default; virtual bool isAvailable() const = 0; - virtual QString errorString() const = 0; virtual bool setKey(const QUuid& dbUuid, const QByteArray& key) = 0; virtual bool getKey(const QUuid& dbUuid, QByteArray& key) = 0; @@ -37,22 +37,32 @@ public: virtual void reset(const QUuid& dbUuid) = 0; virtual void reset() = 0; + + virtual QString errorString() const + { + return m_error; + } + +protected: + QString m_error; }; -class NoQuickUnlock : public QuickUnlockInterface +class QuickUnlockManager final { + Q_DISABLE_COPY(QuickUnlockManager) + public: - bool isAvailable() const override; - QString errorString() const override; + QuickUnlockManager(); + ~QuickUnlockManager(); - bool setKey(const QUuid& dbUuid, const QByteArray& key) override; - bool getKey(const QUuid& dbUuid, QByteArray& key) override; - bool hasKey(const QUuid& dbUuid) const override; + QSharedPointer interface() const; + bool isNativeAvailable() const; - void reset(const QUuid& dbUuid) override; - void reset() override; +private: + QSharedPointer m_nativeInterface; + QSharedPointer m_fallbackInterface; }; -QuickUnlockInterface* getQuickUnlock(); +QuickUnlockManager* getQuickUnlock(); #endif // KEEPASSXC_QUICKUNLOCKINTERFACE_H diff --git a/src/quickunlock/TouchID.cpp b/src/quickunlock/TouchID.cpp new file mode 100644 index 000000000..a36362b63 --- /dev/null +++ b/src/quickunlock/TouchID.cpp @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2025 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "quickunlock/TouchID.h" +#include "gui/osutils/OSUtils.h" + +/** + * Store the serialized database key into the macOS key store. The OS handles encrypt/decrypt operations. + * https://developer.apple.com/documentation/security/keychain_services/keychain_items + */ +bool TouchID::setKey(const QUuid& dbUuid, const QByteArray& key) +{ + if (key.isEmpty()) { + qWarning("TouchID::setKey - provided key is empty"); + return false; + } + + return osUtils->saveSecret(dbUuid.toString(), key); +} + +/** + * Retrieve serialized key data from the macOS Keychain after successful authentication + * with TouchID or Watch interface. + */ +bool TouchID::getKey(const QUuid& dbUuid, QByteArray& key) +{ + key.clear(); + + if (!hasKey(dbUuid)) { + qWarning("TouchID::getKey - No stored key found"); + return false; + } + + return osUtils->getSecret(dbUuid.toString(), key); +} + +bool TouchID::hasKey(const QUuid& dbUuid) const +{ + QByteArray tmp; + return osUtils->getSecret(dbUuid.toString(), tmp); +} + +bool TouchID::isAvailable() const +{ + return macUtils()->isAuthPolicyAvailable(MacUtils::AuthPolicy::TouchId) + || macUtils()->isAuthPolicyAvailable(MacUtils::AuthPolicy::Watch) + || macUtils()->isAuthPolicyAvailable(MacUtils::AuthPolicy::PasswordFallback); +} + +void TouchID::reset(const QUuid& dbUuid) +{ + osUtils->removeSecret(dbUuid.toString()); +} + +void TouchID::reset() +{ + osUtils->removeAllSecrets(); +} diff --git a/src/quickunlock/TouchID.h b/src/quickunlock/TouchID.h index 74e5d9474..886710cbf 100644 --- a/src/quickunlock/TouchID.h +++ b/src/quickunlock/TouchID.h @@ -15,17 +15,14 @@ * along with this program. If not, see . */ -#ifndef KEEPASSX_TOUCHID_H -#define KEEPASSX_TOUCHID_H +#pragma once #include "QuickUnlockInterface.h" -#include class TouchID : public QuickUnlockInterface { public: bool isAvailable() const override; - QString errorString() const override; bool setKey(const QUuid& dbUuid, const QByteArray& passwordKey) override; bool getKey(const QUuid& dbUuid, QByteArray& passwordKey) override; @@ -33,17 +30,4 @@ public: void reset(const QUuid& dbUuid = "") override; void reset() override; - -private: - static bool isWatchAvailable(); - static bool isTouchIdAvailable(); - static bool isPasswordFallbackPossible(); - bool setKey(const QUuid& dbUuid, const QByteArray& passwordKey, const bool ignoreTouchID); - - static void deleteKeyEntry(const QString& accountName); - static QString databaseKeyName(const QUuid& dbUuid); - - QHash m_encryptedMasterKeys; }; - -#endif // KEEPASSX_TOUCHID_H diff --git a/src/quickunlock/TouchID.mm b/src/quickunlock/TouchID.mm deleted file mode 100644 index f7ea18014..000000000 --- a/src/quickunlock/TouchID.mm +++ /dev/null @@ -1,408 +0,0 @@ -#include "quickunlock/TouchID.h" - -#include "crypto/Random.h" -#include "crypto/SymmetricCipher.h" -#include "crypto/CryptoHash.h" -#include "config-keepassx.h" - -#include - -#include -#include -#include -#include - -#include -#include - -#define TOUCH_ID_ENABLE_DEBUG_LOGS() 0 -#if TOUCH_ID_ENABLE_DEBUG_LOGS() -#define debug(...) qWarning(__VA_ARGS__) -#else -inline void debug(const char *message, ...) -{ - Q_UNUSED(message); -} -#endif - -inline std::string StatusToErrorMessage(OSStatus status) -{ - CFStringRef text = SecCopyErrorMessageString(status, NULL); - if (!text) { - return std::to_string(status); - } - - auto msg = CFStringGetCStringPtr(text, kCFStringEncodingUTF8); - std::string result; - if (msg) { - result = msg; - } - CFRelease(text); - return result; -} - -inline void LogStatusError(const char *message, OSStatus status) -{ - if (!status) { - return; - } - - std::string msg = StatusToErrorMessage(status); - debug("%s: %s", message, msg.c_str()); -} - -inline CFMutableDictionaryRef makeDictionary() { - return CFDictionaryCreateMutable(NULL, 0, &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks); -} - -//! Try to delete an existing keychain entry -void TouchID::deleteKeyEntry(const QString& accountName) -{ - NSString* nsAccountName = accountName.toNSString(); // The NSString is released by Qt - - // try to delete an existing entry - CFMutableDictionaryRef query = makeDictionary(); - CFDictionarySetValue(query, kSecClass, kSecClassGenericPassword); - CFDictionarySetValue(query, kSecAttrAccount, (__bridge CFStringRef) nsAccountName); - CFDictionarySetValue(query, kSecReturnData, kCFBooleanFalse); - - // get data from the KeyChain - OSStatus status = SecItemDelete(query); - LogStatusError("TouchID::deleteKeyEntry - Status deleting existing entry", status); -} - -QString TouchID::databaseKeyName(const QUuid& dbUuid) -{ - static const QString keyPrefix = "KeepassXC_TouchID_Keys_"; - return keyPrefix + dbUuid.toString(); -} - -QString TouchID::errorString() const -{ - // TODO - return ""; -} - -void TouchID::reset() -{ - m_encryptedMasterKeys.clear(); -} - - - - -bool TouchID::setKey(const QUuid& dbUuid, const QByteArray& passwordKey, const bool ignoreTouchID) -{ - if (passwordKey.isEmpty()) { - debug("TouchID::setKey - illegal arguments"); - return false; - } - - if (m_encryptedMasterKeys.contains(dbUuid)) { - debug("TouchID::setKey - Already stored key for this database"); - return true; - } - - // generate random AES 256bit key and IV - QByteArray randomKey = randomGen()->randomArray(SymmetricCipher::keySize(SymmetricCipher::Aes256_GCM)); - QByteArray randomIV = randomGen()->randomArray(SymmetricCipher::defaultIvSize(SymmetricCipher::Aes256_GCM)); - - SymmetricCipher aes256Encrypt; - if (!aes256Encrypt.init(SymmetricCipher::Aes256_GCM, SymmetricCipher::Encrypt, randomKey, randomIV)) { - debug("TouchID::setKey - AES initialisation failed"); - return false; - } - - // encrypt and keep result in memory - QByteArray encryptedMasterKey = passwordKey; - if (!aes256Encrypt.finish(encryptedMasterKey)) { - debug("TouchID::getKey - AES encrypt failed: %s", aes256Encrypt.errorString().toUtf8().constData()); - return false; - } - - const QString keyName = databaseKeyName(dbUuid); - - deleteKeyEntry(keyName); // Try to delete the existing key entry - - // prepare adding secure entry to the macOS KeyChain - CFErrorRef error = NULL; - - // We need both runtime and compile time checks here to solve the following problems: - // - Not all flags are available in all OS versions, so we have to check it at compile time - // - Requesting Biometry/TouchID/DevicePassword when to fingerprint sensor is available will result in runtime error - SecAccessControlCreateFlags accessControlFlags = 0; -#if XC_COMPILER_SUPPORT(APPLE_BIOMETRY) - // Needs a special check to work with SecItemAdd, when TouchID is not enrolled and the flag - // is set, the method call fails with an error. But we want to still set this flag if TouchID is - // enrolled but temporarily unavailable due to closed lid - // - // At least on a Hackintosh the enrolled-check does not work, there LAErrorBiometryNotAvailable gets returned instead of - // LAErrorBiometryNotEnrolled. - // - // That's kinda unfortunate, because now you cannot know for sure if TouchID hardware is either temporarily unavailable or not present - // at all, because LAErrorBiometryNotAvailable is used for both cases. - // - // So to make quick unlock fallbacks possible on these machines you have to try to save the key a second time without this flag, if the - // first try fails with an error. - if (!ignoreTouchID) { - // Prefer the non-deprecated flag when available - accessControlFlags = kSecAccessControlBiometryCurrentSet; - } -#elif XC_COMPILER_SUPPORT(TOUCH_ID) - if (!ignoreTouchID) { - accessControlFlags = kSecAccessControlTouchIDCurrentSet; - } -#endif - -#if XC_COMPILER_SUPPORT(WATCH_UNLOCK) - accessControlFlags = accessControlFlags | kSecAccessControlOr | kSecAccessControlWatch; -#endif - -#if XC_COMPILER_SUPPORT(TOUCH_ID) - if (isPasswordFallbackPossible()) { - accessControlFlags = accessControlFlags | kSecAccessControlOr | kSecAccessControlDevicePasscode; - } -#endif - - SecAccessControlRef sacObject = SecAccessControlCreateWithFlags( - kCFAllocatorDefault, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, accessControlFlags, &error); - - if (sacObject == NULL || error != NULL) { - NSError* e = (__bridge NSError*) error; - debug("TouchID::setKey - Error creating security flags: %s", e.localizedDescription.UTF8String); - return false; - } - - NSString *accountName = keyName.toNSString(); // The NSString is released by Qt - - // prepare data (key) to be stored - QByteArray keychainKeyValue = (randomKey + randomIV).toHex(); - CFDataRef keychainValueData = - CFDataCreateWithBytesNoCopy(kCFAllocatorDefault, reinterpret_cast(keychainKeyValue.data()), - keychainKeyValue.length(), kCFAllocatorDefault); - - CFMutableDictionaryRef attributes = makeDictionary(); - CFDictionarySetValue(attributes, kSecClass, kSecClassGenericPassword); - CFDictionarySetValue(attributes, kSecAttrAccount, (__bridge CFStringRef) accountName); - CFDictionarySetValue(attributes, kSecValueData, (__bridge CFDataRef) keychainValueData); - CFDictionarySetValue(attributes, kSecAttrSynchronizable, kCFBooleanFalse); - CFDictionarySetValue(attributes, kSecUseAuthenticationUI, kSecUseAuthenticationUIAllow); - CFDictionarySetValue(attributes, kSecAttrAccessControl, sacObject); - - // add to KeyChain - OSStatus status = SecItemAdd(attributes, NULL); - LogStatusError("TouchID::setKey - Status adding new entry", status); - - CFRelease(sacObject); - CFRelease(attributes); - - // Cleanse the key information from the memory - Botan::secure_scrub_memory(randomKey.data(), randomKey.size()); - Botan::secure_scrub_memory(randomIV.data(), randomIV.size()); - - if (status != errSecSuccess) { - return false; - } - - // memorize which database the stored key is for - m_encryptedMasterKeys.insert(dbUuid, encryptedMasterKey); - debug("TouchID::setKey - Success!"); - return true; -} - -/** - * Generates a random AES 256bit key and uses it to encrypt the PasswordKey that - * protects the database. The encrypted PasswordKey is kept in memory while the - * AES key is stored in the macOS KeyChain protected by either TouchID or Apple Watch. - */ -bool TouchID::setKey(const QUuid& dbUuid, const QByteArray& passwordKey) -{ - if (!setKey(dbUuid,passwordKey, false)) { - debug("TouchID::setKey failed with error trying fallback method without TouchID flag"); - return setKey(dbUuid, passwordKey, true); - } else { - return true; - } -} - -/** - * Checks if an encrypted PasswordKey is available for the given database, tries to - * decrypt it using the KeyChain and if successful, returns it. - */ -bool TouchID::getKey(const QUuid& dbUuid, QByteArray& passwordKey) -{ - passwordKey.clear(); - - if (!hasKey(dbUuid)) { - debug("TouchID::getKey - No stored key found"); - return false; - } - - // query the KeyChain for the AES key - CFMutableDictionaryRef query = makeDictionary(); - - const QString keyName = databaseKeyName(dbUuid); - NSString* accountName = keyName.toNSString(); // The NSString is released by Qt - NSString* touchPromptMessage = - QCoreApplication::translate("DatabaseOpenWidget", "authenticate to access the database") - .toNSString(); // The NSString is released by Qt - - CFDictionarySetValue(query, kSecClass, kSecClassGenericPassword); - CFDictionarySetValue(query, kSecAttrAccount, (__bridge CFStringRef) accountName); - CFDictionarySetValue(query, kSecReturnData, kCFBooleanTrue); - CFDictionarySetValue(query, kSecUseOperationPrompt, (__bridge CFStringRef) touchPromptMessage); - - // get data from the KeyChain - CFTypeRef dataTypeRef = NULL; - OSStatus status = SecItemCopyMatching(query, &dataTypeRef); - CFRelease(query); - - if (status == errSecUserCanceled) { - // user canceled the authentication, return true with empty key - debug("TouchID::getKey - User canceled authentication"); - return true; - } else if (status != errSecSuccess || dataTypeRef == NULL) { - LogStatusError("TouchID::getKey - key query error", status); - return false; - } - - CFDataRef valueData = static_cast(dataTypeRef); - QByteArray dataBytes = QByteArray::fromHex(QByteArray(reinterpret_cast(CFDataGetBytePtr(valueData)), - CFDataGetLength(valueData))); - CFRelease(dataTypeRef); - - // extract AES key and IV from data bytes - QByteArray key = dataBytes.left(SymmetricCipher::keySize(SymmetricCipher::Aes256_GCM)); - QByteArray iv = dataBytes.right(SymmetricCipher::defaultIvSize(SymmetricCipher::Aes256_GCM)); - - SymmetricCipher aes256Decrypt; - if (!aes256Decrypt.init(SymmetricCipher::Aes256_GCM, SymmetricCipher::Decrypt, key, iv)) { - debug("TouchID::getKey - AES initialization failed"); - return false; - } - - // decrypt PasswordKey from memory using AES - passwordKey = m_encryptedMasterKeys[dbUuid]; - if (!aes256Decrypt.finish(passwordKey)) { - passwordKey.clear(); - debug("TouchID::getKey - AES decrypt failed: %s", aes256Decrypt.errorString().toUtf8().constData()); - return false; - } - - // Cleanse the key information from the memory - Botan::secure_scrub_memory(key.data(), key.size()); - Botan::secure_scrub_memory(iv.data(), iv.size()); - - return true; -} - -bool TouchID::hasKey(const QUuid& dbUuid) const -{ - return m_encryptedMasterKeys.contains(dbUuid); -} - -// TODO: Both functions below should probably handle the returned errors to -// provide more information on availability. E.g.: the closed laptop lid results -// in an error (because touch id is not unavailable). That error could be -// displayed to the user when we first check for availability instead of just -// hiding the checkbox. - -//! @return true if Apple Watch is available for authentication. -bool TouchID::isWatchAvailable() -{ -#if XC_COMPILER_SUPPORT(WATCH_UNLOCK) - @try { - LAContext *context = [[LAContext alloc] init]; - - LAPolicy policyCode = LAPolicyDeviceOwnerAuthenticationWithWatch; - NSError *error; - - bool canAuthenticate = [context canEvaluatePolicy:policyCode error:&error]; - [context release]; - if (error) { - debug("Apple Wach available: %d (%ld / %s / %s)", canAuthenticate, - (long)error.code, error.description.UTF8String, - error.localizedDescription.UTF8String); - } else { - debug("Apple Wach available: %d", canAuthenticate); - } - return canAuthenticate; - } @catch (NSException *) { - return false; - } -#else - return false; -#endif -} - -//! @return true if Touch ID is available for authentication. -bool TouchID::isTouchIdAvailable() -{ -#if XC_COMPILER_SUPPORT(TOUCH_ID) - @try { - LAContext *context = [[LAContext alloc] init]; - - LAPolicy policyCode = LAPolicyDeviceOwnerAuthenticationWithBiometrics; - NSError *error; - - bool canAuthenticate = [context canEvaluatePolicy:policyCode error:&error]; - [context release]; - if (error) { - debug("Touch ID available: %d (%ld / %s / %s)", canAuthenticate, - (long)error.code, error.description.UTF8String, - error.localizedDescription.UTF8String); - } else { - debug("Touch ID available: %d", canAuthenticate); - } - return canAuthenticate; - } @catch (NSException *) { - return false; - } -#else - return false; -#endif -} - -bool TouchID::isPasswordFallbackPossible() -{ -#if XC_COMPILER_SUPPORT(TOUCH_ID) - @try { - LAContext *context = [[LAContext alloc] init]; - - LAPolicy policyCode = LAPolicyDeviceOwnerAuthentication; - NSError *error; - - bool canAuthenticate = [context canEvaluatePolicy:policyCode error:&error]; - [context release]; - if (error) { - debug("Password fallback available: %d (%ld / %s / %s)", canAuthenticate, - (long)error.code, error.description.UTF8String, - error.localizedDescription.UTF8String); - } else { - debug("Password fallback available: %d", canAuthenticate); - } - return canAuthenticate; - } @catch (NSException *) { - return false; - } -#else - return false; -#endif -} - -//! @return true if either TouchID or Apple Watch is available at the moment. -bool TouchID::isAvailable() const -{ - // note: we cannot cache the check results because the configuration - // is dynamic in its nature. User can close the laptop lid or take off - // the watch, thus making one (or both) of the authentication types unavailable. - return isWatchAvailable() || isTouchIdAvailable() || isPasswordFallbackPossible(); -} - -/** - * Resets the inner state either for all or for the given database - */ -void TouchID::reset(const QUuid& dbUuid) -{ - m_encryptedMasterKeys.remove(dbUuid); -} diff --git a/src/quickunlock/WindowsHello.cpp b/src/quickunlock/WindowsHello.cpp index 890e3499a..539f00c64 100644 --- a/src/quickunlock/WindowsHello.cpp +++ b/src/quickunlock/WindowsHello.cpp @@ -17,8 +17,9 @@ #include "WindowsHello.h" -#include +#include #include +#include #include #include #include @@ -28,12 +29,14 @@ #include "crypto/CryptoHash.h" #include "crypto/Random.h" #include "crypto/SymmetricCipher.h" +#include "gui/osutils/OSUtils.h" #include #include using namespace winrt; using namespace Windows::Foundation; +using namespace Windows::Foundation::Collections; using namespace Windows::Security::Credentials; using namespace Windows::Security::Cryptography; using namespace Windows::Storage::Streams; @@ -43,17 +46,20 @@ namespace const std::wstring s_winHelloKeyName{L"keepassxc_winhello"}; int g_promptFocusCount = 0; - void queueSecurityPromptFocus(int delay = 500) + void queueSecurityPromptFocus(bool initial, int delay = 500) { + if (initial) { + g_promptFocusCount = 0; + } + QTimer::singleShot(delay, [] { auto hWnd = ::FindWindowA("Credential Dialog Xaml Host", nullptr); if (hWnd) { ::SetForegroundWindow(hWnd); } else if (++g_promptFocusCount <= 3) { - queueSecurityPromptFocus(); - return; + qDebug("WindowsHello - Could not find security prompt window"); + queueSecurityPromptFocus(false); } - g_promptFocusCount = 0; }); } @@ -105,14 +111,9 @@ bool WindowsHello::isAvailable() const return task.get(); } -QString WindowsHello::errorString() const -{ - return m_error; -} - bool WindowsHello::setKey(const QUuid& dbUuid, const QByteArray& data) { - queueSecurityPromptFocus(); + queueSecurityPromptFocus(true); // Generate a random challenge that will be signed by Windows Hello // to create the key. The challenge is also used as the IV. @@ -120,6 +121,7 @@ bool WindowsHello::setKey(const QUuid& dbUuid, const QByteArray& data) auto challenge = Random::instance()->randomArray(ivSize); QByteArray key; if (!deriveEncryptionKey(challenge, key, m_error)) { + m_error = QObject::tr("Windows Hello setup was canceled or failed. Quick unlock has not been enabled."); return false; } @@ -137,28 +139,28 @@ bool WindowsHello::setKey(const QUuid& dbUuid, const QByteArray& data) // Prepend the challenge/IV to the encrypted data encrypted.prepend(challenge); - m_encryptedKeys.insert(dbUuid, encrypted); - return true; + return osUtils->saveSecret(dbUuid.toString(), encrypted); } bool WindowsHello::getKey(const QUuid& dbUuid, QByteArray& data) { data.clear(); - if (!hasKey(dbUuid)) { - m_error = QObject::tr("Failed to get Windows Hello credential."); + QByteArray keydata; + if (!osUtils->getSecret(dbUuid.toString(), keydata)) { + m_error = QObject::tr("Failed to retrieve Windows Hello credential."); return false; } - queueSecurityPromptFocus(); + queueSecurityPromptFocus(true); // Read the previously used challenge and encrypted data auto ivSize = SymmetricCipher::defaultIvSize(SymmetricCipher::Aes256_GCM); - const auto& keydata = m_encryptedKeys.value(dbUuid); auto challenge = keydata.left(ivSize); auto encrypted = keydata.mid(ivSize); - QByteArray key; + QByteArray key; if (!deriveEncryptionKey(challenge, key, m_error)) { + // Error is set in deriveEncryptionKey return false; } @@ -182,15 +184,16 @@ bool WindowsHello::getKey(const QUuid& dbUuid, QByteArray& data) void WindowsHello::reset(const QUuid& dbUuid) { - m_encryptedKeys.remove(dbUuid); + osUtils->removeSecret(dbUuid.toString()); } bool WindowsHello::hasKey(const QUuid& dbUuid) const { - return m_encryptedKeys.contains(dbUuid); + QByteArray tmp; + return osUtils->getSecret(dbUuid.toString(), tmp); } void WindowsHello::reset() { - m_encryptedKeys.clear(); + osUtils->removeAllSecrets(); } diff --git a/src/quickunlock/WindowsHello.h b/src/quickunlock/WindowsHello.h index 9da6e4160..cff2fe13b 100644 --- a/src/quickunlock/WindowsHello.h +++ b/src/quickunlock/WindowsHello.h @@ -20,26 +20,22 @@ #include "QuickUnlockInterface.h" -#include -#include - class WindowsHello : public QuickUnlockInterface { public: WindowsHello() = default; + bool isAvailable() const override; - QString errorString() const override; - void reset() override; bool setKey(const QUuid& dbUuid, const QByteArray& key) override; bool getKey(const QUuid& dbUuid, QByteArray& key) override; bool hasKey(const QUuid& dbUuid) const override; + void reset(const QUuid& dbUuid) override; + void reset() override; private: - QString m_error; - QHash m_encryptedKeys; - Q_DISABLE_COPY(WindowsHello); + Q_DISABLE_COPY(WindowsHello) }; #endif // KEEPASSXC_WINDOWSHELLO_H diff --git a/src/quickunlock/dbus/org.freedesktop.PolicyKit1.Authority.xml b/src/quickunlock/dbus/org.freedesktop.PolicyKit1.Authority.xml index d46d71d2a..b8f5328f8 100644 --- a/src/quickunlock/dbus/org.freedesktop.PolicyKit1.Authority.xml +++ b/src/quickunlock/dbus/org.freedesktop.PolicyKit1.Authority.xml @@ -12,5 +12,10 @@ + + + + +