mirror of
https://github.com/keepassxreboot/keepassxc.git
synced 2025-07-27 00:35:27 -04:00
Passkeys improvements (#10318)
Refactors the Passkey implementation to include more checks and a structure that is more aligned with the official specification. Notable changes: - _BrowserService_ no longer does the checks by itself. A new class _BrowserPasskeysClient_ constructs the relevant objects, acting as a client. _BrowserService_ only acts as a bridge between the client and _BrowserPasskeys_ (authenticator) and calls the relevant popups for user interaction. - A new helper class _PasskeyUtils_ includes the actual checks and parses the objects. - _BrowserPasskeys_ is pretty much intact, but some functions have been moved to PasskeyUtils. - Fixes Ed25519 encoding in _BrowserCBOR_. - Adds new error messages. - User confirmation for Passkey retrieval is also asked even if `discouraged` is used. This goes against the specification, but currently there's no other way to verify the user. - `cross-platform` is also accepted for compatibility. This could be removed if there's a potential issue with it. - Extension data is now handled correctly during Authentication. - Allowed and excluded credentials are now handled correctly. - `KPEX_PASSKEY_GENERATED_USER_ID` is renamed to `KPEX_PASSKEY_CREDENTIAL_ID` - Adds a new option "Allow localhost with Passkeys" to Browser Integration -> Advanced tab. By default it's not allowed to access HTTP sites, but `http://localhost` can be allowed for debugging and testing purposes for local servers. - Add tag `Passkey` to a Passkey entry, or an entry with an imported Passkey. Fixes #10287.
This commit is contained in:
parent
dff2f186ce
commit
ac2b445db6
33 changed files with 1248 additions and 269 deletions
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
|
||||
* Copyright (C) 2024 KeePassXC Team <team@keepassxc.org>
|
||||
* Copyright (C) 2017 Sami Vänttinen <sami.vanttinen@protonmail.com>
|
||||
* Copyright (C) 2013 Francois Ferrand
|
||||
*
|
||||
|
@ -31,7 +31,9 @@
|
|||
#include "gui/osutils/OSUtils.h"
|
||||
#ifdef WITH_XC_BROWSER_PASSKEYS
|
||||
#include "BrowserPasskeys.h"
|
||||
#include "BrowserPasskeysClient.h"
|
||||
#include "BrowserPasskeysConfirmationDialog.h"
|
||||
#include "PasskeyUtils.h"
|
||||
#endif
|
||||
#ifdef Q_OS_MACOS
|
||||
#include "gui/osutils/macutils/MacUtils.h"
|
||||
|
@ -611,7 +613,7 @@ QString BrowserService::getKey(const QString& id)
|
|||
|
||||
#ifdef WITH_XC_BROWSER_PASSKEYS
|
||||
// Passkey registration
|
||||
QJsonObject BrowserService::showPasskeysRegisterPrompt(const QJsonObject& publicKey,
|
||||
QJsonObject BrowserService::showPasskeysRegisterPrompt(const QJsonObject& publicKeyOptions,
|
||||
const QString& origin,
|
||||
const StringPairList& keyList)
|
||||
{
|
||||
|
@ -620,39 +622,23 @@ QJsonObject BrowserService::showPasskeysRegisterPrompt(const QJsonObject& public
|
|||
return getPasskeyError(ERROR_KEEPASS_DATABASE_NOT_OPENED);
|
||||
}
|
||||
|
||||
const auto userJson = publicKey["user"].toObject();
|
||||
const auto username = userJson["name"].toString();
|
||||
const auto userHandle = userJson["id"].toString();
|
||||
const auto rpId = publicKey["rp"]["id"].toString();
|
||||
const auto rpName = publicKey["rp"]["name"].toString();
|
||||
const auto timeoutValue = publicKey["timeout"].toInt();
|
||||
const auto excludeCredentials = publicKey["excludeCredentials"].toArray();
|
||||
const auto attestation = publicKey["attestation"].toString();
|
||||
|
||||
// Check Resident Key requirement
|
||||
const auto authenticatorSelection = publicKey["authenticatorSelection"].toObject();
|
||||
const auto requireResidentKey = authenticatorSelection["requireResidentKey"].toBool();
|
||||
if (requireResidentKey) {
|
||||
return getPasskeyError(ERROR_PASSKEYS_RESIDENT_KEYS_NOT_SUPPORTED);
|
||||
QJsonObject credentialCreationOptions;
|
||||
const auto pkOptionsResult =
|
||||
browserPasskeysClient()->getCredentialCreationOptions(publicKeyOptions, origin, &credentialCreationOptions);
|
||||
if (pkOptionsResult > 0 || credentialCreationOptions.isEmpty()) {
|
||||
return getPasskeyError(pkOptionsResult);
|
||||
}
|
||||
|
||||
// Only support these two for now
|
||||
if (attestation != BrowserPasskeys::PASSKEYS_ATTESTATION_NONE
|
||||
&& attestation != BrowserPasskeys::PASSKEYS_ATTESTATION_DIRECT) {
|
||||
return getPasskeyError(ERROR_PASSKEYS_ATTESTATION_NOT_SUPPORTED);
|
||||
}
|
||||
const auto excludeCredentials = credentialCreationOptions["excludeCredentials"].toArray();
|
||||
const auto rpId = publicKeyOptions["rp"]["id"].toString();
|
||||
const auto timeout = publicKeyOptions["timeout"].toInt();
|
||||
const auto username = credentialCreationOptions["user"].toObject()["name"].toString();
|
||||
|
||||
const auto userVerification = authenticatorSelection["userVerification"].toString();
|
||||
if (!browserPasskeys()->isUserVerificationValid(userVerification)) {
|
||||
return getPasskeyError(ERROR_PASSKEYS_INVALID_USER_VERIFICATION);
|
||||
}
|
||||
|
||||
if (!excludeCredentials.isEmpty() && isPasskeyCredentialExcluded(excludeCredentials, origin, keyList)) {
|
||||
// Parse excludeCredentialDescriptorList
|
||||
if (!excludeCredentials.isEmpty() && isPasskeyCredentialExcluded(excludeCredentials, rpId, keyList)) {
|
||||
return getPasskeyError(ERROR_PASSKEYS_CREDENTIAL_IS_EXCLUDED);
|
||||
}
|
||||
|
||||
const auto existingEntries = getPasskeyEntries(rpId, keyList);
|
||||
const auto timeout = browserPasskeys()->getTimeout(userVerification, timeoutValue);
|
||||
|
||||
raiseWindow();
|
||||
BrowserPasskeysConfirmationDialog confirmDialog;
|
||||
|
@ -660,7 +646,16 @@ QJsonObject BrowserService::showPasskeysRegisterPrompt(const QJsonObject& public
|
|||
|
||||
auto dialogResult = confirmDialog.exec();
|
||||
if (dialogResult == QDialog::Accepted) {
|
||||
const auto publicKeyCredentials = browserPasskeys()->buildRegisterPublicKeyCredential(publicKey, origin);
|
||||
const auto publicKeyCredentials =
|
||||
browserPasskeys()->buildRegisterPublicKeyCredential(credentialCreationOptions);
|
||||
if (publicKeyCredentials.credentialId.isEmpty() || publicKeyCredentials.key.isEmpty()
|
||||
|| publicKeyCredentials.response.isEmpty()) {
|
||||
return getPasskeyError(ERROR_PASSKEYS_UNKNOWN_ERROR);
|
||||
}
|
||||
|
||||
const auto rpName = publicKeyOptions["rp"]["name"].toString();
|
||||
const auto user = credentialCreationOptions["user"].toObject();
|
||||
const auto userId = user["id"].toString();
|
||||
|
||||
if (confirmDialog.isPasskeyUpdated()) {
|
||||
addPasskeyToEntry(confirmDialog.getSelectedEntry(),
|
||||
|
@ -668,7 +663,7 @@ QJsonObject BrowserService::showPasskeysRegisterPrompt(const QJsonObject& public
|
|||
rpName,
|
||||
username,
|
||||
publicKeyCredentials.credentialId,
|
||||
userHandle,
|
||||
userId,
|
||||
publicKeyCredentials.key);
|
||||
} else {
|
||||
addPasskeyToGroup(nullptr,
|
||||
|
@ -677,7 +672,7 @@ QJsonObject BrowserService::showPasskeysRegisterPrompt(const QJsonObject& public
|
|||
rpName,
|
||||
username,
|
||||
publicKeyCredentials.credentialId,
|
||||
userHandle,
|
||||
userId,
|
||||
publicKeyCredentials.key);
|
||||
}
|
||||
|
||||
|
@ -690,7 +685,7 @@ QJsonObject BrowserService::showPasskeysRegisterPrompt(const QJsonObject& public
|
|||
}
|
||||
|
||||
// Passkey authentication
|
||||
QJsonObject BrowserService::showPasskeysAuthenticationPrompt(const QJsonObject& publicKey,
|
||||
QJsonObject BrowserService::showPasskeysAuthenticationPrompt(const QJsonObject& publicKeyOptions,
|
||||
const QString& origin,
|
||||
const StringPairList& keyList)
|
||||
{
|
||||
|
@ -699,24 +694,21 @@ QJsonObject BrowserService::showPasskeysAuthenticationPrompt(const QJsonObject&
|
|||
return getPasskeyError(ERROR_KEEPASS_DATABASE_NOT_OPENED);
|
||||
}
|
||||
|
||||
const auto userVerification = publicKey["userVerification"].toString();
|
||||
if (!browserPasskeys()->isUserVerificationValid(userVerification)) {
|
||||
return getPasskeyError(ERROR_PASSKEYS_INVALID_USER_VERIFICATION);
|
||||
QJsonObject assertionOptions;
|
||||
const auto assertionResult =
|
||||
browserPasskeysClient()->getAssertionOptions(publicKeyOptions, origin, &assertionOptions);
|
||||
if (assertionResult > 0 || assertionOptions.isEmpty()) {
|
||||
return getPasskeyError(assertionResult);
|
||||
}
|
||||
|
||||
// Parse "allowCredentials"
|
||||
const auto rpId = publicKey["rpId"].toString();
|
||||
const auto entries = getPasskeyAllowedEntries(publicKey, rpId, keyList);
|
||||
// Get allowed entries from RP ID
|
||||
const auto rpId = assertionOptions["rpId"].toString();
|
||||
const auto entries = getPasskeyAllowedEntries(assertionOptions, rpId, keyList);
|
||||
if (entries.isEmpty()) {
|
||||
return getPasskeyError(ERROR_KEEPASS_NO_LOGINS_FOUND);
|
||||
}
|
||||
|
||||
// With single entry, if no verification is needed, return directly
|
||||
if (entries.count() == 1 && userVerification == BrowserPasskeys::REQUIREMENT_DISCOURAGED) {
|
||||
return getPublicKeyCredentialFromEntry(entries.first(), publicKey, origin);
|
||||
}
|
||||
|
||||
const auto timeout = browserPasskeys()->getTimeout(userVerification, publicKey["timeout"].toInt());
|
||||
const auto timeout = publicKeyOptions["timeout"].toInt();
|
||||
|
||||
raiseWindow();
|
||||
BrowserPasskeysConfirmationDialog confirmDialog;
|
||||
|
@ -725,7 +717,21 @@ QJsonObject BrowserService::showPasskeysAuthenticationPrompt(const QJsonObject&
|
|||
if (dialogResult == QDialog::Accepted) {
|
||||
hideWindow();
|
||||
const auto selectedEntry = confirmDialog.getSelectedEntry();
|
||||
return getPublicKeyCredentialFromEntry(selectedEntry, publicKey, origin);
|
||||
if (!selectedEntry) {
|
||||
return getPasskeyError(ERROR_PASSKEYS_UNKNOWN_ERROR);
|
||||
}
|
||||
|
||||
const auto privateKeyPem = selectedEntry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_PRIVATE_KEY_PEM);
|
||||
const auto credentialId = selectedEntry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_CREDENTIAL_ID);
|
||||
const auto userHandle = selectedEntry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_USER_HANDLE);
|
||||
|
||||
auto publicKeyCredential =
|
||||
browserPasskeys()->buildGetPublicKeyCredential(assertionOptions, credentialId, userHandle, privateKeyPem);
|
||||
if (publicKeyCredential.isEmpty()) {
|
||||
return getPasskeyError(ERROR_PASSKEYS_UNKNOWN_ERROR);
|
||||
}
|
||||
|
||||
return publicKeyCredential;
|
||||
}
|
||||
|
||||
hideWindow();
|
||||
|
@ -797,10 +803,11 @@ void BrowserService::addPasskeyToEntry(Entry* entry,
|
|||
entry->beginUpdate();
|
||||
|
||||
entry->attributes()->set(BrowserPasskeys::KPEX_PASSKEY_USERNAME, username);
|
||||
entry->attributes()->set(BrowserPasskeys::KPEX_PASSKEY_GENERATED_USER_ID, credentialId, true);
|
||||
entry->attributes()->set(BrowserPasskeys::KPEX_PASSKEY_CREDENTIAL_ID, credentialId, true);
|
||||
entry->attributes()->set(BrowserPasskeys::KPEX_PASSKEY_PRIVATE_KEY_PEM, privateKey, true);
|
||||
entry->attributes()->set(BrowserPasskeys::KPEX_PASSKEY_RELYING_PARTY, rpId);
|
||||
entry->attributes()->set(BrowserPasskeys::KPEX_PASSKEY_USER_HANDLE, userHandle, true);
|
||||
entry->addTag(tr("Passkey"));
|
||||
|
||||
entry->endUpdate();
|
||||
}
|
||||
|
@ -1342,18 +1349,21 @@ QList<Entry*> BrowserService::getPasskeyEntries(const QString& rpId, const Strin
|
|||
}
|
||||
|
||||
// Get all entries for the site that are allowed by the server
|
||||
QList<Entry*> BrowserService::getPasskeyAllowedEntries(const QJsonObject& publicKey,
|
||||
QList<Entry*> BrowserService::getPasskeyAllowedEntries(const QJsonObject& assertionOptions,
|
||||
const QString& rpId,
|
||||
const StringPairList& keyList)
|
||||
{
|
||||
QList<Entry*> entries;
|
||||
const auto allowedCredentials = browserPasskeys()->getAllowedCredentialsFromPublicKey(publicKey);
|
||||
const auto allowedCredentials = passkeyUtils()->getAllowedCredentialsFromAssertionOptions(assertionOptions);
|
||||
if (!assertionOptions["allowCredentials"].toArray().isEmpty() && allowedCredentials.isEmpty()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
for (const auto& entry : getPasskeyEntries(rpId, keyList)) {
|
||||
// If allowedCredentials.isEmpty() check if entry contains an extra attribute for user handle.
|
||||
// If that is found, the entry should be allowed.
|
||||
// See: https://w3c.github.io/webauthn/#dom-authenticatorassertionresponse-userhandle
|
||||
if (allowedCredentials.contains(entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_GENERATED_USER_ID))
|
||||
if (allowedCredentials.contains(entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_CREDENTIAL_ID))
|
||||
|| (allowedCredentials.isEmpty()
|
||||
&& entry->attributes()->hasKey(BrowserPasskeys::KPEX_PASSKEY_USER_HANDLE))) {
|
||||
entries << entry;
|
||||
|
@ -1363,18 +1373,9 @@ QList<Entry*> BrowserService::getPasskeyAllowedEntries(const QJsonObject& public
|
|||
return entries;
|
||||
}
|
||||
|
||||
QJsonObject
|
||||
BrowserService::getPublicKeyCredentialFromEntry(const Entry* entry, const QJsonObject& publicKey, const QString& origin)
|
||||
{
|
||||
const auto privateKeyPem = entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_PRIVATE_KEY_PEM);
|
||||
const auto credentialId = entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_GENERATED_USER_ID);
|
||||
const auto userHandle = entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_USER_HANDLE);
|
||||
return browserPasskeys()->buildGetPublicKeyCredential(publicKey, origin, credentialId, userHandle, privateKeyPem);
|
||||
}
|
||||
|
||||
// Checks if the same user ID already exists for the current site
|
||||
// Checks if the same user ID already exists for the current RP ID
|
||||
bool BrowserService::isPasskeyCredentialExcluded(const QJsonArray& excludeCredentials,
|
||||
const QString& origin,
|
||||
const QString& rpId,
|
||||
const StringPairList& keyList)
|
||||
{
|
||||
QStringList allIds;
|
||||
|
@ -1382,9 +1383,9 @@ bool BrowserService::isPasskeyCredentialExcluded(const QJsonArray& excludeCreden
|
|||
allIds << cred["id"].toString();
|
||||
}
|
||||
|
||||
const auto passkeyEntries = getPasskeyEntries(origin, keyList);
|
||||
const auto passkeyEntries = getPasskeyEntries(rpId, keyList);
|
||||
return std::any_of(passkeyEntries.begin(), passkeyEntries.end(), [&](const auto& entry) {
|
||||
return allIds.contains(entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_GENERATED_USER_ID));
|
||||
return allIds.contains(entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_CREDENTIAL_ID));
|
||||
});
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue