diff --git a/share/translations/keepassxc_en.ts b/share/translations/keepassxc_en.ts index 345a6b7f9..60d0d8f93 100644 --- a/share/translations/keepassxc_en.ts +++ b/share/translations/keepassxc_en.ts @@ -701,16 +701,12 @@ Hide notes in the entry preview panel - - Quick unlock can only be remembered when using Touch ID or Windows Hello - - Enable database quick unlock by default - Remember quick unlock after database is closed (Touch ID / Windows Hello only) + Remember quick unlock after database is closed @@ -1739,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 @@ -1795,10 +1787,6 @@ Are you sure you want to continue with this file?. <a href="#" style="text-decoration: underline">I have a key file</a> - - Enable Quick Unlock - - Reset @@ -1815,6 +1803,10 @@ Are you sure you want to continue with this file?. Press ESC again to close this database + + Quick Unlock + + DatabaseSettingWidgetMetaData @@ -9154,10 +9146,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. @@ -9324,10 +9312,6 @@ This option is deprecated, use --set-key-file instead. Quick Unlock Pin Entry - - Enter a %1 to %2 digit pin to use for quick unlock: - - Pin setup was canceled. Quick unlock has not been enabled. @@ -9344,22 +9328,6 @@ This option is deprecated, use --set-key-file instead. Pin entry was canceled. - - Maximum pin attempts have been reached. - - - - Failed to store key in Linux Keyring. Quick unlock has not been enabled. - - - - Could not locate key in Linux Keyring. - - - - Could not read key in Linux Keyring. - - No Polkit authentication agent was available. @@ -9457,6 +9425,30 @@ This option is deprecated, use --set-key-file instead. 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 d12da9dc0..9c5f1456d 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -228,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/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 index bcf48defb..d4ca6ca8d 100644 --- a/src/quickunlock/PinUnlock.cpp +++ b/src/quickunlock/PinUnlock.cpp @@ -1,4 +1,4 @@ -/* +/* * Copyright (C) 2025 KeePassXC Team * * This program is free software: you can redistribute it and/or modify @@ -21,62 +21,86 @@ #include "crypto/Random.h" #include "crypto/SymmetricCipher.h" #include "crypto/kdf/Argon2Kdf.h" +#include "gui/osutils/OSUtils.h" #include #include -namespace -{ - constexpr int MIN_PIN_LENGTH = 6; - constexpr int MAX_PIN_LENGTH = 10; - constexpr int MAX_PIN_ATTEMPTS = 3; -} // namespace +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; } -QString PinUnlock::errorString() const -{ - return m_error; -} - -bool PinUnlock::setKey(const QUuid& dbUuid, const QByteArray& data) +bool PinUnlock::promptPin(int attempt, QByteArray& sessionKey) { QString pin; - QRegularExpression pinRegex("^\\d+$"); - while (true) { + + 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 a %1–%2 digit pin to use for quick unlock:").arg(MIN_PIN_LENGTH).arg(MAX_PIN_LENGTH), + QObject::tr("Enter quick unlock pin (%1 of %2 attempts):").arg(attempt).arg(MAX_PIN_ATTEMPTS), QLineEdit::Password, {}, &ok); if (!ok) { - m_error = QObject::tr("Pin setup was canceled. Quick unlock has not been enabled."); + // User canceled the pin entry dialog, record pin attempts + m_error = QObject::tr("Pin entry was canceled."); return false; } - - // Validate pin criteria - if (pin.length() >= MIN_PIN_LENGTH && pin.length() <= MAX_PIN_LENGTH && pinRegex.match(pin).hasMatch()) { - break; - } } // Hash the pin then run it through Argon2 to derive the encryption key - QByteArray key(32, '\0'); + sessionKey.fill('\0', 32); Argon2Kdf kdf(Argon2Kdf::Type::Argon2id); CryptoHash hash(CryptoHash::Sha256); hash.addData(pin.toLatin1()); - if (!kdf.transform(hash.result(), key)) { + 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)); @@ -92,68 +116,53 @@ bool PinUnlock::setKey(const QUuid& dbUuid, const QByteArray& data) return false; } - // Prepend the IV to the encrypted data - encrypted.prepend(iv); - // Store the encrypted data and pin attempts - m_encryptedKeys.insert(dbUuid, qMakePair(1, encrypted)); - + // Store the encrypted data + saveKey(dbUuid, encrypted.prepend(iv)); return true; } bool PinUnlock::getKey(const QUuid& dbUuid, QByteArray& data) { data.clear(); - if (!hasKey(dbUuid)) { - m_error = QObject::tr("Failed to get credentials for quick unlock."); - return false; - } - - const auto& pairData = m_encryptedKeys.value(dbUuid); - - // Restrict pin attempts per database - for (int pinAttempts = pairData.first; pinAttempts <= MAX_PIN_ATTEMPTS; ++pinAttempts) { - bool ok = false; - auto pin = QInputDialog::getText( - nullptr, - QObject::tr("Quick Unlock Pin Entry"), - QObject::tr("Enter quick unlock pin (%1 of %2 attempts):").arg(pinAttempts).arg(MAX_PIN_ATTEMPTS), - QLineEdit::Password, - {}, - &ok); - - if (!ok) { - m_error = QObject::tr("Pin entry was canceled."); + 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; } + } - // Hash the pin then run it through Argon2 to derive the encryption key - QByteArray key(32, '\0'); - Argon2Kdf kdf(Argon2Kdf::Type::Argon2id); - CryptoHash hash(CryptoHash::Sha256); - hash.addData(pin.toLatin1()); - if (!kdf.transform(hash.result(), key)) { - m_error = QObject::tr("Failed to derive key using Argon2"); + // 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& keydata = pairData.second; - auto challenge = keydata.left(ivSize); - auto encrypted = keydata.mid(ivSize); + 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, challenge)) { + if (!cipher.init(SymmetricCipher::Aes256_GCM, SymmetricCipher::Decrypt, key, iv)) { m_error = QObject::tr("Failed to init KeePassXC crypto."); return false; } - // Store the decrypted data into the passed parameter - data = encrypted; + // Attempt to decrypt the key data + data = pairData.second.mid(ivSize); if (cipher.finish(data)) { - // Reset the pin attempts - m_encryptedKeys.insert(dbUuid, qMakePair(1, keydata)); + // Decryption succeeded, reset the pin attempts + m_encryptedKeys.insert(dbUuid, qMakePair(1, pairData.second)); return true; } } @@ -164,17 +173,35 @@ bool PinUnlock::getKey(const QUuid& dbUuid, QByteArray& data) 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 { - return m_encryptedKeys.contains(dbUuid); + 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 index acae32eeb..081b74405 100644 --- a/src/quickunlock/PinUnlock.h +++ b/src/quickunlock/PinUnlock.h @@ -28,7 +28,6 @@ public: PinUnlock() = default; 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; @@ -37,8 +36,16 @@ public: 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: - QString m_error; + void saveKey(const QUuid& dbUuid, const QByteArray& key); + QHash> m_encryptedKeys; Q_DISABLE_COPY(PinUnlock) diff --git a/src/quickunlock/Polkit.cpp b/src/quickunlock/Polkit.cpp index c9fa5f75c..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)) { + 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)) { + // Encrypt the database key + QByteArray encrypted = data; + if (!aes256Encrypt.finish(encrypted)) { m_error = QObject::tr("Failed to encrypt key data."); - qDebug() << "polkit aes encrypt failed: " << aes256Encrypt.errorString(); return false; } - // Add the iv/key pair into the linux keyring - key_serial_t key_serial = add_key("user", - getKeyName(dbUuid).toStdString().c_str(), - keychainKeyValue.constData(), - keychainKeyValue.size(), - KEY_SPEC_PROCESS_KEYRING); - if (key_serial < 0) { - m_error = QObject::tr("Failed to store key in Linux Keyring. Quick unlock has not been enabled."); - 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,60 +212,11 @@ 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 Linux 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 Linux 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("Failed to init KeePassXC crypto."); - qDebug() << "polkit aes init failed"; - return false; - } - - key = encryptedMasterKey; - if (!aes256Decrypt.finish(key)) { - key.clear(); - m_error = QObject::tr("Failed to decrypt key data."); - 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; } @@ -233,15 +226,12 @@ bool Polkit::getKey(const QUuid& dbUuid, QByteArray& key) } else { m_error = QObject::tr("Polkit authorization failed."); } - return false; } bool Polkit::hasKey(const QUuid& dbUuid) const { - if (!m_encryptedMasterKeys.contains(dbUuid)) { - return false; - } - - return find_key_by_type_and_desc("user", getKeyName(dbUuid).toStdString().c_str(), KEY_SPEC_PROCESS_KEYRING) != -1; + // 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.h b/src/quickunlock/QuickUnlockInterface.h index 6a999ac2a..8ca09d47e 100644 --- a/src/quickunlock/QuickUnlockInterface.h +++ b/src/quickunlock/QuickUnlockInterface.h @@ -30,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; @@ -38,6 +37,14 @@ public: virtual void reset(const QUuid& dbUuid) = 0; virtual void reset() = 0; + + virtual QString errorString() const + { + return m_error; + } + +protected: + QString m_error; }; class QuickUnlockManager final 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 ce73e87f9..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,15 +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); }; - -#endif // KEEPASSX_TOUCHID_H diff --git a/src/quickunlock/TouchID.mm b/src/quickunlock/TouchID.mm deleted file mode 100644 index d2e6a88ba..000000000 --- a/src/quickunlock/TouchID.mm +++ /dev/null @@ -1,397 +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 - -static const auto s_touchIdKeyPrefix = QStringLiteral("KeepassXC_TouchID_Keys_"); - -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 - Error deleting existing entry", status); -} - -QString TouchID::databaseKeyName(const QUuid& dbUuid) -{ - return s_touchIdKeyPrefix + dbUuid.toString(); -} - -QString TouchID::errorString() const -{ - // TODO - return ""; -} - -void TouchID::reset() -{ - // Query for all generic password items - CFMutableDictionaryRef query = makeDictionary(); - CFDictionarySetValue(query, kSecClass, kSecClassGenericPassword); - CFDictionarySetValue(query, kSecReturnAttributes, kCFBooleanTrue); - CFDictionarySetValue(query, kSecMatchLimit, kSecMatchLimitAll); - - CFTypeRef result = nullptr; - OSStatus status = SecItemCopyMatching(query, &result); - if (status != errSecSuccess || !result) { - LogStatusError("TouchID::deleteAllKeyEntriesWithPrefix - Error querying keychain", status); - CFRelease(query); - return; - } - - NSArray* items = (__bridge NSArray*)result; - for (NSDictionary* item in items) { - NSString* account = item[(id)kSecAttrAccount]; - if (account && [account hasPrefix:s_touchIdKeyPrefix.toNSString()]) { - // Build a query to delete this item - CFMutableDictionaryRef delQuery = makeDictionary(); - CFDictionarySetValue(delQuery, kSecClass, kSecClassGenericPassword); - CFDictionarySetValue(delQuery, kSecAttrAccount, (__bridge CFStringRef)account); - OSStatus delStatus = SecItemDelete(delQuery); - LogStatusError("TouchID::deleteAllKeyEntriesWithPrefix - Error deleting item", delStatus); - CFRelease(delQuery); - } - } - CFRelease(result); - CFRelease(query); -} - -/** - * 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, const bool ignoreTouchID) -{ - if (key.isEmpty()) { - debug("TouchID::setKey - illegal arguments"); - return false; - } - - const auto keyName = databaseKeyName(dbUuid); - // Try to delete the existing key entry - deleteKeyEntry(keyName); - - // 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) { - auto e = (__bridge NSError*) error; - debug("TouchID::setKey - Error creating security flags: %s", e.localizedDescription.UTF8String); - return false; - } - - auto accountName = keyName.toNSString(); - auto keyBase64 = key.toBase64(); - - // prepare data (key) to be stored - auto keyValueData = CFDataCreateWithBytesNoCopy( - kCFAllocatorDefault, reinterpret_cast(keyBase64.data()), - keyBase64.length(), kCFAllocatorDefault); - - auto attributes = makeDictionary(); - CFDictionarySetValue(attributes, kSecClass, kSecClassGenericPassword); - CFDictionarySetValue(attributes, kSecAttrAccount, (__bridge CFStringRef) accountName); - CFDictionarySetValue(attributes, kSecValueData, (__bridge CFDataRef) keyValueData); - CFDictionarySetValue(attributes, kSecAttrSynchronizable, kCFBooleanFalse); - CFDictionarySetValue(attributes, kSecUseAuthenticationUI, kSecUseAuthenticationUIAllow); -#ifndef QT_DEBUG - // Only use TouchID when in release build, also requires application entitlements and signing - CFDictionarySetValue(attributes, kSecAttrAccessControl, sacObject); -#endif - - // add to KeyChain - OSStatus status = SecItemAdd(attributes, NULL); - LogStatusError("TouchID::setKey - Error adding new keychain item", status); - - CFRelease(sacObject); - CFRelease(attributes); - - // Cleanse the key information from the memory - if (status != errSecSuccess) { - return false; - } - - 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; - } -} - -/** - * 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)) { - 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; - } - - // Convert value returned to serialized key - CFDataRef valueData = static_cast(dataTypeRef); - key = QByteArray::fromBase64(QByteArray(reinterpret_cast(CFDataGetBytePtr(valueData)), - CFDataGetLength(valueData))); - CFRelease(dataTypeRef); - - return true; -} - -bool TouchID::hasKey(const QUuid& dbUuid) const -{ - const QString keyName = databaseKeyName(dbUuid); - NSString* accountName = keyName.toNSString(); // The NSString is released by Qt - - CFMutableDictionaryRef query = makeDictionary(); - CFDictionarySetValue(query, kSecClass, kSecClassGenericPassword); - CFDictionarySetValue(query, kSecAttrAccount, (__bridge CFStringRef) accountName); - CFDictionarySetValue(query, kSecReturnData, kCFBooleanFalse); - - CFTypeRef item = NULL; - OSStatus status = SecItemCopyMatching(query, &item); - CFRelease(query); - - return status == errSecSuccess; -} - -// 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 Watch is not available: %s", error.localizedDescription.UTF8String); - } - 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 is not available: %s", error.localizedDescription.UTF8String); - } - 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) -{ - deleteKeyEntry(databaseKeyName(dbUuid)); -} diff --git a/src/quickunlock/WindowsHello.cpp b/src/quickunlock/WindowsHello.cpp index 7290ac73c..539f00c64 100644 --- a/src/quickunlock/WindowsHello.cpp +++ b/src/quickunlock/WindowsHello.cpp @@ -17,7 +17,7 @@ #include "WindowsHello.h" -#include +#include #include #include #include @@ -29,6 +29,7 @@ #include "crypto/CryptoHash.h" #include "crypto/Random.h" #include "crypto/SymmetricCipher.h" +#include "gui/osutils/OSUtils.h" #include #include @@ -45,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; }); } @@ -99,47 +103,6 @@ namespace } }); } - - void storeCredential(const QUuid& uuid, const QByteArray& data) - { - auto vault = PasswordVault(); - vault.Add({s_winHelloKeyName, - winrt::to_hstring(uuid.toString().toStdString()), - winrt::to_hstring(data.toBase64().toStdString())}); - } - - void removeCredential(const QUuid& uuid) - { - try { - auto vault = PasswordVault(); - vault.Remove({s_winHelloKeyName, winrt::to_hstring(uuid.toString().toStdString()), L"nodata"}); - } catch (winrt::hresult_error const& ex) { - } - } - - void resetCredentials() - { - auto vault = PasswordVault(); - auto credentials = vault.FindAllByResource(s_winHelloKeyName); - for (const auto& credential : credentials) { - try { - vault.Remove(credential); - } catch (winrt::hresult_error const& ex) { - } - } - } - - QByteArray loadCredential(const QUuid& uuid) - { - QByteArray data; - try { - auto vault = PasswordVault(); - auto credential = vault.Retrieve(s_winHelloKeyName, winrt::to_hstring(uuid.toString().toStdString())); - data = QByteArray::fromBase64(QByteArray::fromStdString(winrt::to_string(credential.Password()))); - } catch (winrt::hresult_error const& ex) { - } - return data; - } } // namespace bool WindowsHello::isAvailable() const @@ -148,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. @@ -181,28 +139,28 @@ bool WindowsHello::setKey(const QUuid& dbUuid, const QByteArray& data) // Prepend the challenge/IV to the encrypted data encrypted.prepend(challenge); - storeCredential(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 = loadCredential(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; } @@ -226,15 +184,16 @@ bool WindowsHello::getKey(const QUuid& dbUuid, QByteArray& data) void WindowsHello::reset(const QUuid& dbUuid) { - removeCredential(dbUuid); + osUtils->removeSecret(dbUuid.toString()); } bool WindowsHello::hasKey(const QUuid& dbUuid) const { - return !loadCredential(dbUuid).isEmpty(); + QByteArray tmp; + return osUtils->getSecret(dbUuid.toString(), tmp); } void WindowsHello::reset() { - resetCredentials(); + osUtils->removeAllSecrets(); } diff --git a/src/quickunlock/WindowsHello.h b/src/quickunlock/WindowsHello.h index 0f6008590..cff2fe13b 100644 --- a/src/quickunlock/WindowsHello.h +++ b/src/quickunlock/WindowsHello.h @@ -26,7 +26,6 @@ public: WindowsHello() = default; 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; @@ -36,8 +35,6 @@ public: void reset() override; private: - QString m_error; - Q_DISABLE_COPY(WindowsHello) }; 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 @@ + + + + +