keepassxc/src/browser/PasskeyUtils.cpp

355 lines
12 KiB
C++
Raw Normal View History

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.
2024-03-06 12:42:01 +00:00
/*
* Copyright (C) 2024 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* 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 "PasskeyUtils.h"
#include "BrowserMessageBuilder.h"
#include "BrowserPasskeys.h"
#include "core/Tools.h"
#include "core/UrlTools.h"
#include <QList>
#include <QUrl>
Q_GLOBAL_STATIC(PasskeyUtils, s_passkeyUtils);
PasskeyUtils* PasskeyUtils::instance()
{
return s_passkeyUtils;
}
int PasskeyUtils::checkLimits(const QJsonObject& pkOptions) const
{
const auto challenge = pkOptions["challenge"].toString();
if (challenge.isEmpty() || challenge.length() < 16) {
return ERROR_PASSKEYS_INVALID_CHALLENGE;
}
const auto userIdBase64 = pkOptions["user"]["id"].toString();
const auto userId = browserMessageBuilder()->getArrayFromBase64(userIdBase64);
if (userId.isEmpty() || (userId.length() < 1 || userId.length() > 64)) {
return ERROR_PASSKEYS_INVALID_USER_ID;
}
return PASSKEYS_SUCCESS;
}
// Basic check for the object that it contains necessary variables in a correct form
bool PasskeyUtils::checkCredentialCreationOptions(const QJsonObject& credentialCreationOptions) const
{
if (!credentialCreationOptions["attestation"].isString()
|| credentialCreationOptions["attestation"].toString().isEmpty()
|| !credentialCreationOptions["clientDataJSON"].isObject()
|| credentialCreationOptions["clientDataJSON"].toObject().isEmpty()
|| !credentialCreationOptions["rp"].isObject() || credentialCreationOptions["rp"].toObject().isEmpty()
|| !credentialCreationOptions["user"].isObject() || credentialCreationOptions["user"].toObject().isEmpty()
|| !credentialCreationOptions["residentKey"].isBool() || credentialCreationOptions["residentKey"].isUndefined()
|| !credentialCreationOptions["userPresence"].isBool()
|| credentialCreationOptions["userPresence"].isUndefined()
|| !credentialCreationOptions["userVerification"].isBool()
|| credentialCreationOptions["userVerification"].isUndefined()
|| !credentialCreationOptions["credTypesAndPubKeyAlgs"].isArray()
|| credentialCreationOptions["credTypesAndPubKeyAlgs"].toArray().isEmpty()
|| !credentialCreationOptions["excludeCredentials"].isArray()
|| credentialCreationOptions["excludeCredentials"].isUndefined()) {
return false;
}
return true;
}
// Basic check for the object that it contains necessary variables in a correct form
bool PasskeyUtils::checkCredentialAssertionOptions(const QJsonObject& assertionOptions) const
{
if (!assertionOptions["clientDataJson"].isObject() || assertionOptions["clientDataJson"].toObject().isEmpty()
|| !assertionOptions["rpId"].isString() || assertionOptions["rpId"].toString().isEmpty()
|| !assertionOptions["userPresence"].isBool() || assertionOptions["userPresence"].isUndefined()
|| !assertionOptions["userVerification"].isBool() || assertionOptions["userVerification"].isUndefined()) {
return false;
}
return true;
}
int PasskeyUtils::getEffectiveDomain(const QString& origin, QString* result) const
{
if (!result) {
return ERROR_PASSKEYS_ORIGIN_NOT_ALLOWED;
}
if (origin.isEmpty()) {
return ERROR_PASSKEYS_ORIGIN_NOT_ALLOWED;
}
const auto effectiveDomain = QUrl::fromUserInput(origin).host();
if (!isDomain(effectiveDomain)) {
return ERROR_PASSKEYS_DOMAIN_IS_NOT_VALID;
}
*result = effectiveDomain;
return PASSKEYS_SUCCESS;
}
int PasskeyUtils::validateRpId(const QJsonValue& rpIdValue, const QString& effectiveDomain, QString* result) const
{
if (!result) {
return ERROR_PASSKEYS_DOMAIN_RPID_MISMATCH;
}
if (rpIdValue.isUndefined()) {
return ERROR_PASSKEYS_DOMAIN_RPID_MISMATCH;
}
if (effectiveDomain.isEmpty()) {
return ERROR_PASSKEYS_ORIGIN_NOT_ALLOWED;
}
const auto rpId = rpIdValue.toString();
if (!isRegistrableDomainSuffix(rpId, effectiveDomain)) {
return ERROR_PASSKEYS_DOMAIN_RPID_MISMATCH;
}
if (rpId == effectiveDomain) {
*result = effectiveDomain;
return PASSKEYS_SUCCESS;
}
*result = rpId;
return PASSKEYS_SUCCESS;
}
// https://www.w3.org/TR/2021/REC-webauthn-2-20210408/#dom-publickeycredentialcreationoptions-attestation
QString PasskeyUtils::parseAttestation(const QString& attestation) const
{
return attestation == BrowserPasskeys::PASSKEYS_ATTESTATION_DIRECT ? BrowserPasskeys::PASSKEYS_ATTESTATION_DIRECT
: BrowserPasskeys::PASSKEYS_ATTESTATION_NONE;
}
QJsonArray PasskeyUtils::parseCredentialTypes(const QJsonArray& credentialTypes) const
{
QJsonArray credTypesAndPubKeyAlgs;
if (credentialTypes.isEmpty()) {
// Set default values
credTypesAndPubKeyAlgs.push_back(QJsonObject({
{"type", BrowserPasskeys::PUBLIC_KEY},
{"alg", WebAuthnAlgorithms::ES256},
}));
credTypesAndPubKeyAlgs.push_back(QJsonObject({
{"type", BrowserPasskeys::PUBLIC_KEY},
{"alg", WebAuthnAlgorithms::RS256},
}));
} else {
for (const auto current : credentialTypes) {
if (current["type"] != BrowserPasskeys::PUBLIC_KEY || current["alg"].isUndefined()) {
continue;
}
const auto currentAlg = current["alg"].toInt();
if (currentAlg != WebAuthnAlgorithms::ES256 && currentAlg != WebAuthnAlgorithms::RS256
&& currentAlg != WebAuthnAlgorithms::EDDSA) {
continue;
}
credTypesAndPubKeyAlgs.push_back(QJsonObject({
{"type", current["type"]},
{"alg", currentAlg},
}));
}
}
return credTypesAndPubKeyAlgs;
}
bool PasskeyUtils::isAuthenticatorSelectionValid(const QJsonObject& authenticatorSelection) const
{
const auto authenticatorAttachment = authenticatorSelection["authenticatorAttachment"].toString();
if (!authenticatorAttachment.isEmpty() && authenticatorAttachment != BrowserPasskeys::ATTACHMENT_PLATFORM
&& authenticatorAttachment != BrowserPasskeys::ATTACHMENT_CROSS_PLATFORM) {
return false;
}
const auto requireResidentKey = authenticatorSelection["requireResidentKey"].toBool();
if (requireResidentKey && !BrowserPasskeys::SUPPORT_RESIDENT_KEYS) {
return false;
}
const auto residentKey = authenticatorSelection["residentKey"].toString();
if (residentKey == "required" && !BrowserPasskeys::SUPPORT_RESIDENT_KEYS) {
return false;
}
if (residentKey.isEmpty() && requireResidentKey && !BrowserPasskeys::SUPPORT_RESIDENT_KEYS) {
return false;
}
const auto userVerification = authenticatorSelection["userVerification"].toBool();
if (userVerification && !BrowserPasskeys::SUPPORT_USER_VERIFICATION) {
return false;
}
return true;
}
bool PasskeyUtils::isRegistrableDomainSuffix(const QString& hostSuffixString, const QString& originalHost) const
{
if (hostSuffixString.isEmpty()) {
return false;
}
if (!isDomain(originalHost)) {
return false;
}
const auto hostSuffix = QUrl::fromUserInput(hostSuffixString).host();
if (hostSuffix == originalHost) {
return true;
}
if (!isDomain(hostSuffix)) {
return false;
}
const auto prefixedHostSuffix = QString(".%1").arg(hostSuffix);
if (!originalHost.endsWith(prefixedHostSuffix)) {
return false;
}
if (hostSuffix == urlTools()->getTopLevelDomainFromUrl(hostSuffix)) {
return false;
}
const auto originalPublicSuffix = urlTools()->getTopLevelDomainFromUrl(originalHost);
if (originalPublicSuffix.isEmpty()) {
return false;
}
if (originalPublicSuffix.endsWith(prefixedHostSuffix)) {
return false;
}
if (!hostSuffix.endsWith(QString(".%1").arg(originalPublicSuffix))) {
return false;
}
return true;
}
bool PasskeyUtils::isDomain(const QString& hostName) const
{
const auto domain = QUrl::fromUserInput(hostName).host();
return !domain.isEmpty() && !domain.endsWith('.') && Tools::isAsciiString(domain)
&& !urlTools()->domainHasIllegalCharacters(domain) && !urlTools()->isIpAddress(hostName);
}
bool PasskeyUtils::isUserVerificationValid(const QString& userVerification) const
{
return QStringList({BrowserPasskeys::REQUIREMENT_PREFERRED,
BrowserPasskeys::REQUIREMENT_REQUIRED,
BrowserPasskeys::REQUIREMENT_DISCOURAGED})
.contains(userVerification);
}
bool PasskeyUtils::isOriginAllowedWithLocalhost(bool allowLocalhostWithPasskeys, const QString& origin) const
{
if (origin.startsWith("https://") || (allowLocalhostWithPasskeys && origin.startsWith("file://"))) {
return true;
}
if (!allowLocalhostWithPasskeys) {
return false;
}
const auto host = QUrl::fromUserInput(origin).host();
return host == "localhost" || host == "localhost." || host.endsWith(".localhost") || host.endsWith(".localhost.");
}
bool PasskeyUtils::isResidentKeyRequired(const QJsonObject& authenticatorSelection) const
{
if (authenticatorSelection.isEmpty()) {
return false;
}
const auto residentKey = authenticatorSelection["residentKey"].toString();
if (residentKey == BrowserPasskeys::REQUIREMENT_REQUIRED
|| (BrowserPasskeys::SUPPORT_RESIDENT_KEYS && residentKey == BrowserPasskeys::REQUIREMENT_PREFERRED)) {
return true;
} else if (residentKey == BrowserPasskeys::REQUIREMENT_DISCOURAGED) {
return false;
}
return authenticatorSelection["requireResidentKey"].toBool();
}
bool PasskeyUtils::isUserVerificationRequired(const QJsonObject& authenticatorSelection) const
{
const auto userVerification = authenticatorSelection["userVerification"].toString();
return userVerification == BrowserPasskeys::REQUIREMENT_REQUIRED
|| (userVerification == BrowserPasskeys::REQUIREMENT_PREFERRED
&& BrowserPasskeys::SUPPORT_USER_VERIFICATION);
}
QByteArray PasskeyUtils::buildExtensionData(QJsonObject& extensionObject) const
{
// Only supports "credProps" and "uvm" for now
const QStringList allowedKeys = {"credProps", "uvm"};
// Remove unsupported keys
for (const auto& key : extensionObject.keys()) {
if (!allowedKeys.contains(key)) {
extensionObject.remove(key);
}
}
auto extensionData = m_browserCbor.cborEncodeExtensionData(extensionObject);
if (!extensionData.isEmpty()) {
return extensionData;
}
return {};
}
QJsonObject PasskeyUtils::buildClientDataJson(const QJsonObject& publicKey, const QString& origin, bool get) const
{
QJsonObject clientData;
clientData["challenge"] = publicKey["challenge"];
clientData["crossOrigin"] = false;
clientData["origin"] = origin;
clientData["type"] = get ? QString("webauthn.get") : QString("webauthn.create");
return clientData;
}
QStringList PasskeyUtils::getAllowedCredentialsFromAssertionOptions(const QJsonObject& assertionOptions) const
{
QStringList allowedCredentials;
for (const auto& credential : assertionOptions["allowCredentials"].toArray()) {
const auto cred = credential.toObject();
const auto id = cred["id"].toString();
const auto transports = cred["transports"].toArray();
2024-03-12 15:20:56 +00:00
const auto hasSupportedTransport = transports.isEmpty()
|| (transports.contains(BrowserPasskeys::AUTHENTICATOR_TRANSPORT_INTERNAL)
|| transports.contains(BrowserPasskeys::AUTHENTICATOR_TRANSPORT_NFC)
|| transports.contains(BrowserPasskeys::AUTHENTICATOR_TRANSPORT_USB));
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.
2024-03-06 12:42:01 +00:00
if (cred["type"].toString() == BrowserPasskeys::PUBLIC_KEY && hasSupportedTransport && !id.isEmpty()) {
allowedCredentials << id;
}
}
return allowedCredentials;
}