WIP: Enable centralized secret storage

* Also enables pin unlock to be stored

TODO: Clean up pin unlock interface with polkit
This commit is contained in:
Jonathan White 2025-07-19 11:14:42 -04:00
parent 67b550bb6e
commit f15ba49fc6
No known key found for this signature in database
GPG key ID: 440FC65F2E0C6E01
22 changed files with 857 additions and 723 deletions

View file

@ -701,16 +701,12 @@
<source>Hide notes in the entry preview panel</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Quick unlock can only be remembered when using Touch ID or Windows Hello</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Enable database quick unlock by default</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Remember quick unlock after database is closed (Touch ID / Windows Hello only)</source>
<source>Remember quick unlock after database is closed</source>
<translation type="unfinished"></translation>
</message>
</context>
@ -1739,10 +1735,6 @@ To prevent this error from appearing, you must go to &quot;Database Settings / S
<source>Cannot use database file as key file</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>authenticate to access the database</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Failed to authenticate with Quick Unlock: %1</source>
<translation type="unfinished"></translation>
@ -1795,10 +1787,6 @@ Are you sure you want to continue with this file?.</source>
<source>&lt;a href=&quot;#&quot; style=&quot;text-decoration: underline&quot;&gt;I have a key file&lt;/a&gt;</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Enable Quick Unlock</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Reset</source>
<translation type="unfinished"></translation>
@ -1815,6 +1803,10 @@ Are you sure you want to continue with this file?.</source>
<source>Press ESC again to close this database</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Quick Unlock</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>DatabaseSettingWidgetMetaData</name>
@ -9154,10 +9146,6 @@ This option is deprecated, use --set-key-file instead.</source>
<source>Failed to encrypt key data.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Failed to get Windows Hello credential.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Failed to decrypt key data.</source>
<translation type="unfinished"></translation>
@ -9324,10 +9312,6 @@ This option is deprecated, use --set-key-file instead.</source>
<source>Quick Unlock Pin Entry</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Enter a %1 to %2 digit pin to use for quick unlock:</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Pin setup was canceled. Quick unlock has not been enabled.</source>
<translation type="unfinished"></translation>
@ -9344,22 +9328,6 @@ This option is deprecated, use --set-key-file instead.</source>
<source>Pin entry was canceled.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Maximum pin attempts have been reached.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Failed to store key in Linux Keyring. Quick unlock has not been enabled.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Could not locate key in Linux Keyring.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Could not read key in Linux Keyring.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>No Polkit authentication agent was available.</source>
<translation type="unfinished"></translation>
@ -9457,6 +9425,30 @@ This option is deprecated, use --set-key-file instead.</source>
<source>Format to use when exporting. Available choices are &apos;xml&apos;, &apos;csv&apos; or &apos;html&apos;. Defaults to &apos;xml&apos;.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Enter a %1%2 digit pin to use for quick unlock:</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Failed to derive key using Argon2</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Too many pin attempts.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>No key is stored for this database.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Failed to obtain session key.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Failed to retrieve Windows Hello credential.</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>QtIOCompressor</name>

View file

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

View file

@ -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 = {});

View file

@ -17,14 +17,22 @@
*/
#import "AppKitImpl.h"
#import "MacUtils.h"
#import <QWindow>
#import <QMenu>
#import <QMenuBar>
#import <Cocoa/Cocoa.h>
#import <CoreFoundation/CoreFoundation.h>
#import <Foundation/Foundation.h>
#import <LocalAuthentication/LocalAuthentication.h>
#import <Security/Security.h>
#if __clang_major__ >= 13 && MAC_OS_X_VERSION_MIN_REQUIRED >= MAC_OS_VERSION_12_3
#import <ScreenCaptureKit/ScreenCaptureKit.h>
#endif
#include "config-keepassx.h"
@implementation AppKitImpl
- (id) initWithObject:(AppKit*)appkit
@ -340,3 +348,222 @@ void AppKit::configureWindowAndHelpMenus(QMainWindow* window, QMenu* helpMenu)
{
[static_cast<id>(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<NSError*>(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<const UInt8*>(keyBase64.data()),
keyBase64.length(), kCFAllocatorDefault);
auto attributes = CFDictionaryCreateMutable(nullptr, 0, &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks);
CFDictionarySetValue(attributes, kSecClass, kSecClassGenericPassword);
CFDictionarySetValue(attributes, kSecAttrAccount, static_cast<CFStringRef>(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<CFStringRef>(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<CFDataRef>(dataTypeRef);
secretData = QByteArray::fromBase64(QByteArray(reinterpret_cast<const char*>(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<CFStringRef>(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<NSArray*>(result)) {
NSString* account = item[static_cast<id>(kSecAttrAccount)];
if (account && [account hasPrefix:s_touchIdKeyPrefix.toNSString()]) {
auto delQuery = CFDictionaryCreateMutable(nullptr, 0, &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks);
CFDictionarySetValue(delQuery, kSecClass, kSecClassGenericPassword);
CFDictionarySetValue(delQuery, kSecAttrAccount, static_cast<CFStringRef>(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;
}
}

View file

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

View file

@ -30,6 +30,11 @@
#include <QStandardPaths>
#include <QStyle>
#include <QTextStream>
extern "C" {
#include <keyutils.h>
}
#ifdef WITH_XC_X11
#include <QX11Info>
@ -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;
}

View file

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

View file

@ -20,11 +20,24 @@
#include <QApplication>
#include <QDir>
#include <QSettings>
#include <QUuid>
#include <QWindow>
#include <Windows.h>
#include <winrt/base.h>
#include <winrt/windows.foundation.collections.h>
#include <winrt/windows.security.credentials.h>
#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> 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;
}

View file

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

View file

@ -1,4 +1,4 @@
/*
/*
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
*
* 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 <QInputDialog>
#include <QRegularExpression>
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();
}

View file

@ -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<QUuid, QPair<int, QByteArray>> m_encryptedKeys;
Q_DISABLE_COPY(PinUnlock)

View file

@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -23,8 +23,8 @@
#include "gui/osutils/nixutils/NixUtils.h"
#include <QDebug>
#include <QFile>
#include <QtDBus>
#include <botan/mem_ops.h>
#include <cerrno>
@ -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<uint>(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<const char*>(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);
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -15,36 +15,34 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#ifndef KEEPASSX_POLKIT_H
#define KEEPASSX_POLKIT_H
#pragma once
#include "QuickUnlockInterface.h"
#include "PinUnlock.h"
#include "polkit_dbus.h"
#include <QHash>
#include <QScopedPointer>
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<QUuid, QByteArray> m_encryptedMasterKeys;
QHash<QUuid, QByteArray> m_sessionKeys;
QScopedPointer<org::freedesktop::PolicyKit1::Authority> m_polkit;
};
#endif // KEEPASSX_POLKIT_H

View file

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

View file

@ -1,5 +1,21 @@
#ifndef KEEPASSX_POLKITDBUSTYPES_H
#define KEEPASSX_POLKITDBUSTYPES_H
/*
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 or (at your option)
* version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <QtDBus>
@ -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<QString, QString> annotations;
static void registerMetaType();
friend QDBusArgument& operator<<(QDBusArgument& argument, const PolkitActionDescription& action);
friend const QDBusArgument& operator>>(const QDBusArgument& argument, PolkitActionDescription& action);
};
typedef QList<PolkitActionDescription> PolkitActionDescriptionList;
Q_DECLARE_METATYPE(PolkitSubject);
Q_DECLARE_METATYPE(PolkitAuthorizationResults);
#endif // KEEPASSX_POLKITDBUSTYPES_H
Q_DECLARE_METATYPE(PolkitActionDescription);
Q_DECLARE_METATYPE(PolkitActionDescriptionList);

View file

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

View file

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

View file

@ -15,17 +15,14 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#ifndef KEEPASSX_TOUCHID_H
#define KEEPASSX_TOUCHID_H
#pragma once
#include "QuickUnlockInterface.h"
#include <QHash>
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

View file

@ -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 <botan/mem_ops.h>
#include <Foundation/Foundation.h>
#include <CoreFoundation/CoreFoundation.h>
#include <LocalAuthentication/LocalAuthentication.h>
#include <Security/Security.h>
#include <QCoreApplication>
#include <QString>
#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<const UInt8 *>(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<CFDataRef>(dataTypeRef);
key = QByteArray::fromBase64(QByteArray(reinterpret_cast<const char*>(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));
}

View file

@ -17,7 +17,7 @@
#include "WindowsHello.h"
#include <Userconsentverifierinterop.h>
#include <Windows.h>
#include <winrt/base.h>
#include <winrt/windows.foundation.collections.h>
#include <winrt/windows.foundation.h>
@ -29,6 +29,7 @@
#include "crypto/CryptoHash.h"
#include "crypto/Random.h"
#include "crypto/SymmetricCipher.h"
#include "gui/osutils/OSUtils.h"
#include <QTimer>
#include <QWindow>
@ -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();
}

View file

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

View file

@ -12,5 +12,10 @@
<annotation name="org.qtproject.QtDBus.QtTypeName.In0" value="PolkitSubject"/>
<annotation name="org.qtproject.QtDBus.QtTypeName.In2" value="QMap&lt;QString, QString&gt;"/>
</method>
<method name="EnumerateActions">
<arg type="s" name="locale" direction="in" />
<arg type="a(ssssssuuua{ss})" name="action_descriptions" direction="out" />
<annotation name="org.qtproject.QtDBus.QtTypeName.Out0" value="PolkitActionDescriptionList"/>
</method>
</interface>
</node>