From 61cb573f623314bee51f1c3b0ac46014ee95ca7d Mon Sep 17 00:00:00 2001 From: varjolintu Date: Sat, 17 Jan 2026 14:34:49 +0200 Subject: [PATCH] Add support for Related Origin Requests with passkeys --- src/browser/BrowserAction.cpp | 12 +++- src/browser/BrowserMessageBuilder.cpp | 14 ++++- src/browser/BrowserMessageBuilder.h | 3 +- src/browser/BrowserPasskeysClient.cpp | 24 +++++--- src/browser/BrowserPasskeysClient.h | 13 ++-- src/browser/BrowserService.cpp | 12 ++-- src/browser/BrowserService.h | 4 +- src/browser/PasskeyUtils.cpp | 35 ++++++++++- src/browser/PasskeyUtils.h | 3 +- src/gui/UrlTools.cpp | 14 +++-- src/gui/UrlTools.h | 4 +- tests/TestBrowser.cpp | 15 ++++- tests/TestBrowser.h | 3 +- tests/TestPasskeys.cpp | 86 +++++++++++++++++++++++++-- tests/TestPasskeys.h | 5 +- tests/TestUrlTools.cpp | 14 ++++- tests/TestUrlTools.h | 3 +- 17 files changed, 224 insertions(+), 40 deletions(-) diff --git a/src/browser/BrowserAction.cpp b/src/browser/BrowserAction.cpp index 67cf7f0dfd..0c52b068fb 100644 --- a/src/browser/BrowserAction.cpp +++ b/src/browser/BrowserAction.cpp @@ -1,5 +1,5 @@ /* - * Copyright (C) 2024 KeePassXC Team + * Copyright (C) 2026 KeePassXC Team * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -546,8 +546,11 @@ QJsonObject BrowserAction::handlePasskeysGet(const QJsonObject& json, const QStr return getErrorReply(action, ERROR_PASSKEYS_INVALID_URL_PROVIDED); } + const auto relatedOrigins = + browserMessageBuilder()->getStringListFromJsonArray(browserRequest.getArray("relatedOrigins")); const auto keyList = getConnectionKeys(browserRequest); - const auto response = browserService()->showPasskeysAuthenticationPrompt(publicKey, origin, keyList); + const auto response = + browserService()->showPasskeysAuthenticationPrompt(publicKey, origin, relatedOrigins, keyList); const Parameters params{{"response", response}}; return buildResponse(action, browserRequest.incrementedNonce, params); @@ -580,8 +583,11 @@ QJsonObject BrowserAction::handlePasskeysRegister(const QJsonObject& json, const } const auto groupName = browserRequest.getString("groupName"); + const auto relatedOrigins = + browserMessageBuilder()->getStringListFromJsonArray(browserRequest.getArray("relatedOrigins")); const auto keyList = getConnectionKeys(browserRequest); - const auto response = browserService()->showPasskeysRegisterPrompt(publicKey, origin, groupName, keyList); + const auto response = + browserService()->showPasskeysRegisterPrompt(publicKey, origin, relatedOrigins, groupName, keyList); const Parameters params{{"response", response}}; return buildResponse(action, browserRequest.incrementedNonce, params); diff --git a/src/browser/BrowserMessageBuilder.cpp b/src/browser/BrowserMessageBuilder.cpp index e7b398c6a0..53f3c21c81 100644 --- a/src/browser/BrowserMessageBuilder.cpp +++ b/src/browser/BrowserMessageBuilder.cpp @@ -1,5 +1,5 @@ /* - * Copyright (C) 2023 KeePassXC Team + * Copyright (C) 2026 KeePassXC Team * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -370,3 +370,15 @@ QString BrowserMessageBuilder::getSha256HashAsBase64(const QString& str) const { return getBase64FromArray(QCryptographicHash::hash(str.toUtf8(), QCryptographicHash::Sha256)); } + +QStringList BrowserMessageBuilder::getStringListFromJsonArray(const QJsonArray& jsonArray) const +{ + QStringList stringList; + for (const auto& item : jsonArray) { + if (item.isString()) { + stringList << item.toString(); + } + } + + return stringList; +} diff --git a/src/browser/BrowserMessageBuilder.h b/src/browser/BrowserMessageBuilder.h index 5a2f96e166..fa7f25b3e3 100644 --- a/src/browser/BrowserMessageBuilder.h +++ b/src/browser/BrowserMessageBuilder.h @@ -1,5 +1,5 @@ /* - * Copyright (C) 2024 KeePassXC Team + * Copyright (C) 2026 KeePassXC Team * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -106,6 +106,7 @@ class BrowserMessageBuilder QByteArray getArrayFromBase64(const QString& base64str) const; QByteArray getSha256Hash(const QString& str) const; QString getSha256HashAsBase64(const QString& str) const; + QStringList getStringListFromJsonArray(const QJsonArray& jsonArray) const; private: Q_DISABLE_COPY(BrowserMessageBuilder); diff --git a/src/browser/BrowserPasskeysClient.cpp b/src/browser/BrowserPasskeysClient.cpp index 64857a72ae..f149bd21e3 100644 --- a/src/browser/BrowserPasskeysClient.cpp +++ b/src/browser/BrowserPasskeysClient.cpp @@ -1,5 +1,5 @@ /* - * Copyright (C) 2024 KeePassXC Team + * Copyright (C) 2026 KeePassXC Team * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -33,6 +33,7 @@ BrowserPasskeysClient* BrowserPasskeysClient::instance() // https://www.w3.org/TR/2019/REC-webauthn-1-20190304/#createCredential int BrowserPasskeysClient::getCredentialCreationOptions(const QJsonObject& publicKeyOptions, const QString& origin, + const QStringList& relatedOrigins, QJsonObject* result) const { if (!result || publicKeyOptions.isEmpty()) { @@ -41,14 +42,14 @@ int BrowserPasskeysClient::getCredentialCreationOptions(const QJsonObject& publi // Check validity of some basic values const auto checkResultError = passkeyUtils()->checkLimits(publicKeyOptions); - if (checkResultError > 0) { + if (checkResultError != PASSKEYS_SUCCESS) { return checkResultError; } // Get effective domain QString effectiveDomain; const auto effectiveDomainResponse = passkeyUtils()->getEffectiveDomain(origin, &effectiveDomain); - if (effectiveDomainResponse > 0) { + if (effectiveDomainResponse != PASSKEYS_SUCCESS) { return effectiveDomainResponse; } @@ -56,8 +57,11 @@ int BrowserPasskeysClient::getCredentialCreationOptions(const QJsonObject& publi QString rpId; const auto rpName = publicKeyOptions["rp"]["name"].toString(); const auto rpIdResponse = passkeyUtils()->validateRpId(publicKeyOptions["rp"]["id"], effectiveDomain, &rpId); - if (rpIdResponse > 0) { - return rpIdResponse; + if (rpIdResponse != PASSKEYS_SUCCESS) { + // Validate Related Origin Requests if found + if (relatedOrigins.isEmpty() || !passkeyUtils()->validateRelatedOrigins(relatedOrigins, origin)) { + return rpIdResponse; + } } // Check PublicKeyCredentialTypes @@ -126,6 +130,7 @@ int BrowserPasskeysClient::getCredentialCreationOptions(const QJsonObject& publi // https://www.w3.org/TR/2019/REC-webauthn-1-20190304/#getAssertion int BrowserPasskeysClient::getAssertionOptions(const QJsonObject& publicKeyOptions, const QString& origin, + const QStringList& relatedOrigins, QJsonObject* result) const { if (!result || publicKeyOptions.isEmpty()) { @@ -135,15 +140,18 @@ int BrowserPasskeysClient::getAssertionOptions(const QJsonObject& publicKeyOptio // Get effective domain QString effectiveDomain; const auto effectiveDomainResponse = passkeyUtils()->getEffectiveDomain(origin, &effectiveDomain); - if (effectiveDomainResponse > 0) { + if (effectiveDomainResponse != PASSKEYS_SUCCESS) { return effectiveDomainResponse; } // Validate RP ID QString rpId; const auto rpIdResponse = passkeyUtils()->validateRpId(publicKeyOptions["rpId"], effectiveDomain, &rpId); - if (rpIdResponse > 0) { - return rpIdResponse; + if (rpIdResponse != PASSKEYS_SUCCESS) { + // Validate Related Origin Requests if found + if (relatedOrigins.isEmpty() || !passkeyUtils()->validateRelatedOrigins(relatedOrigins, origin)) { + return rpIdResponse; + } } // Extensions diff --git a/src/browser/BrowserPasskeysClient.h b/src/browser/BrowserPasskeysClient.h index 24040bd3e2..4c8355864a 100644 --- a/src/browser/BrowserPasskeysClient.h +++ b/src/browser/BrowserPasskeysClient.h @@ -1,5 +1,5 @@ /* - * Copyright (C) 2024 KeePassXC Team + * Copyright (C) 2026 KeePassXC Team * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -31,9 +31,14 @@ class BrowserPasskeysClient : public QObject ~BrowserPasskeysClient() = default; static BrowserPasskeysClient* instance(); - int - getCredentialCreationOptions(const QJsonObject& publicKeyOptions, const QString& origin, QJsonObject* result) const; - int getAssertionOptions(const QJsonObject& publicKeyOptions, const QString& origin, QJsonObject* result) const; + int getCredentialCreationOptions(const QJsonObject& publicKeyOptions, + const QString& origin, + const QStringList& relatedOrigins, + QJsonObject* result) const; + int getAssertionOptions(const QJsonObject& publicKeyOptions, + const QString& origin, + const QStringList& relatedOrigins, + QJsonObject* result) const; private: Q_DISABLE_COPY(BrowserPasskeysClient); diff --git a/src/browser/BrowserService.cpp b/src/browser/BrowserService.cpp index a421d080fb..17fbf45a6d 100644 --- a/src/browser/BrowserService.cpp +++ b/src/browser/BrowserService.cpp @@ -638,6 +638,7 @@ QString BrowserService::getKey(const QString& id) // Passkey registration QJsonObject BrowserService::showPasskeysRegisterPrompt(const QJsonObject& publicKeyOptions, const QString& origin, + const QStringList& relatedOrigins, const QString& groupName, const StringPairList& keyList) { @@ -647,9 +648,9 @@ QJsonObject BrowserService::showPasskeysRegisterPrompt(const QJsonObject& public } QJsonObject credentialCreationOptions; - const auto pkOptionsResult = - browserPasskeysClient()->getCredentialCreationOptions(publicKeyOptions, origin, &credentialCreationOptions); - if (pkOptionsResult > 0 || credentialCreationOptions.isEmpty()) { + const auto pkOptionsResult = browserPasskeysClient()->getCredentialCreationOptions( + publicKeyOptions, origin, relatedOrigins, &credentialCreationOptions); + if (pkOptionsResult != PASSKEYS_SUCCESS || credentialCreationOptions.isEmpty()) { return getPasskeyError(pkOptionsResult); } @@ -737,6 +738,7 @@ QJsonObject BrowserService::showPasskeysRegisterPrompt(const QJsonObject& public // Passkey authentication QJsonObject BrowserService::showPasskeysAuthenticationPrompt(const QJsonObject& publicKeyOptions, const QString& origin, + const QStringList& relatedOrigins, const StringPairList& keyList) { auto db = getDatabase(); @@ -746,8 +748,8 @@ QJsonObject BrowserService::showPasskeysAuthenticationPrompt(const QJsonObject& QJsonObject assertionOptions; const auto assertionResult = - browserPasskeysClient()->getAssertionOptions(publicKeyOptions, origin, &assertionOptions); - if (assertionResult > 0 || assertionOptions.isEmpty()) { + browserPasskeysClient()->getAssertionOptions(publicKeyOptions, origin, relatedOrigins, &assertionOptions); + if (assertionResult != PASSKEYS_SUCCESS || assertionOptions.isEmpty()) { return getPasskeyError(assertionResult); } diff --git a/src/browser/BrowserService.h b/src/browser/BrowserService.h index c59f9303d6..6dd0dabdfc 100644 --- a/src/browser/BrowserService.h +++ b/src/browser/BrowserService.h @@ -1,5 +1,5 @@ /* - * Copyright (C) 2025 KeePassXC Team + * Copyright (C) 2026 KeePassXC Team * Copyright (C) 2017 Sami Vänttinen * Copyright (C) 2013 Francois Ferrand * @@ -90,10 +90,12 @@ class BrowserService : public QObject #ifdef WITH_XC_BROWSER_PASSKEYS QJsonObject showPasskeysRegisterPrompt(const QJsonObject& publicKeyOptions, const QString& origin, + const QStringList& relatedOrigins, const QString& groupName, const StringPairList& keyList); QJsonObject showPasskeysAuthenticationPrompt(const QJsonObject& publicKeyOptions, const QString& origin, + const QStringList& relatedOrigins, const StringPairList& keyList); void addPasskeyToGroup(const QSharedPointer& db, Group* group, diff --git a/src/browser/PasskeyUtils.cpp b/src/browser/PasskeyUtils.cpp index b28c94dbbc..a893638144 100644 --- a/src/browser/PasskeyUtils.cpp +++ b/src/browser/PasskeyUtils.cpp @@ -1,5 +1,5 @@ /* - * Copyright (C) 2025 KeePassXC Team + * Copyright (C) 2026 KeePassXC Team * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -25,6 +25,8 @@ #include #include +#define MAX_SEEN_LABELS 10 + Q_GLOBAL_STATIC(PasskeyUtils, s_passkeyUtils); PasskeyUtils* PasskeyUtils::instance() @@ -135,6 +137,37 @@ int PasskeyUtils::validateRpId(const QJsonValue& rpIdValue, const QString& effec return PASSKEYS_SUCCESS; } +// The steps for validation: https://www.w3.org/TR/webauthn-3/#sctn-validating-relation-origin +bool PasskeyUtils::validateRelatedOrigins(const QStringList& relatedOrigins, const QString& origin) const +{ + QSet labelsSeen; + + for (const auto& originItem : relatedOrigins) { + QString effectiveDomain; + if (passkeyUtils()->getEffectiveDomain(originItem, &effectiveDomain) != PASSKEYS_SUCCESS) { + continue; + } + + const auto label = urlTools()->getBaseDomainFromUrl(originItem, true); + if (label.isNull() || label.isEmpty()) { + continue; + } + + if (labelsSeen.size() >= MAX_SEEN_LABELS && !labelsSeen.contains(label)) { + continue; + } + + if (originItem == origin) { + return true; + } + + if (labelsSeen.size() < MAX_SEEN_LABELS) { + labelsSeen.insert(label); + } + } + return false; +} + // https://www.w3.org/TR/2021/REC-webauthn-2-20210408/#dom-publickeycredentialcreationoptions-attestation QString PasskeyUtils::parseAttestation(const QString& attestation) const { diff --git a/src/browser/PasskeyUtils.h b/src/browser/PasskeyUtils.h index b1e6a48ab3..47c2a2b787 100644 --- a/src/browser/PasskeyUtils.h +++ b/src/browser/PasskeyUtils.h @@ -1,5 +1,5 @@ /* - * Copyright (C) 2025 KeePassXC Team + * Copyright (C) 2026 KeePassXC Team * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -50,6 +50,7 @@ class PasskeyUtils : public QObject bool checkCredentialAssertionOptions(const QJsonObject& assertionOptions) const; int getEffectiveDomain(const QString& origin, QString* result) const; int validateRpId(const QJsonValue& rpIdValue, const QString& effectiveDomain, QString* result) const; + bool validateRelatedOrigins(const QStringList& relatedOrigins, const QString& origin) const; QString parseAttestation(const QString& attestation) const; QJsonArray parseCredentialTypes(const QJsonArray& credentialTypes) const; bool isAuthenticatorSelectionValid(const QJsonObject& authenticatorSelection) const; diff --git a/src/gui/UrlTools.cpp b/src/gui/UrlTools.cpp index 917f2048c6..149b020421 100644 --- a/src/gui/UrlTools.cpp +++ b/src/gui/UrlTools.cpp @@ -1,5 +1,5 @@ /* - * Copyright (C) 2025 KeePassXC Team + * Copyright (C) 2026 KeePassXC Team * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -53,10 +53,13 @@ QUrl UrlTools::getRedirectTarget(QNetworkReply* reply) const /** * Gets the base domain of URL or hostname. * + * If returnOnlyLabel is true, return only the Registrable Origin Label: + * https://www.w3.org/TR/webauthn-3/#registrable-origin-label + * * Returns the base domain, e.g. https://another.example.co.uk -> example.co.uk * Up-to-date list can be found: https://publicsuffix.org/list/public_suffix_list.dat */ -QString UrlTools::getBaseDomainFromUrl(const QString& url) const +QString UrlTools::getBaseDomainFromUrl(const QString& url, bool returnOnlyLabel) const { auto qUrl = QUrl::fromUserInput(url); @@ -74,8 +77,11 @@ QString UrlTools::getBaseDomainFromUrl(const QString& url) const host.chop(tld.length() + 1); // Split the URL and select the last part, e.g. https://another.example -> example QString baseDomain = host.split('.').last(); - // Append the top level domain back to the URL, e.g. example -> example.co.uk - baseDomain.append(QString(".%1").arg(tld)); + + if (!returnOnlyLabel) { + // Append the top level domain back to the URL, e.g. example -> example.co.uk + baseDomain.append(QString(".%1").arg(tld)); + } return baseDomain; } diff --git a/src/gui/UrlTools.h b/src/gui/UrlTools.h index 5cadb45d8c..dd01d4ba1c 100644 --- a/src/gui/UrlTools.h +++ b/src/gui/UrlTools.h @@ -1,5 +1,5 @@ /* - * Copyright (C) 2025 KeePassXC Team + * Copyright (C) 2026 KeePassXC Team * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -36,7 +36,7 @@ class UrlTools : public QObject #if defined(WITH_XC_NETWORKING) || defined(WITH_XC_BROWSER) QUrl getRedirectTarget(QNetworkReply* reply) const; - QString getBaseDomainFromUrl(const QString& url) const; + QString getBaseDomainFromUrl(const QString& url, bool returnOnlyLabel = false) const; QString getTopLevelDomainFromUrl(const QString& url) const; bool isIpAddress(const QString& host) const; #endif diff --git a/tests/TestBrowser.cpp b/tests/TestBrowser.cpp index a2610748a8..b976f0e370 100644 --- a/tests/TestBrowser.cpp +++ b/tests/TestBrowser.cpp @@ -1,5 +1,5 @@ /* - * Copyright (C) 2025 KeePassXC Team + * Copyright (C) 2026 KeePassXC Team * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -23,7 +23,9 @@ #include "core/Tools.h" #include "crypto/Crypto.h" +#include #include +#include #include #include @@ -111,6 +113,17 @@ void TestBrowser::testIncrementNonce() QCOMPARE(result, INCREMENTEDNONCE); } +void TestBrowser::testGetStringListFromJsonArray() +{ + QJsonArray array = {QString("first"), QString("second")}; + QJsonArray mixedArray = {1, 2.2, QString()}; + QJsonArray emptyArray = {}; + + QCOMPARE(browserMessageBuilder()->getStringListFromJsonArray(array), QStringList({"first", "second"})); + QCOMPARE(browserMessageBuilder()->getStringListFromJsonArray(mixedArray), QStringList({""})); + QCOMPARE(browserMessageBuilder()->getStringListFromJsonArray(emptyArray), QStringList({})); +} + void TestBrowser::testBuildResponse() { const auto object = QJsonObject{{"test", true}}; diff --git a/tests/TestBrowser.h b/tests/TestBrowser.h index 6a99e085dc..ff6e830bcf 100644 --- a/tests/TestBrowser.h +++ b/tests/TestBrowser.h @@ -1,5 +1,5 @@ /* - * Copyright (C) 2025 KeePassXC Team + * Copyright (C) 2026 KeePassXC Team * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -36,6 +36,7 @@ private slots: void testDecryptMessage(); void testGetBase64FromKey(); void testIncrementNonce(); + void testGetStringListFromJsonArray(); void testBuildResponse(); void testSortPriority(); void testSortPriority_data(); diff --git a/tests/TestPasskeys.cpp b/tests/TestPasskeys.cpp index 82f649e9cc..51aa71459e 100644 --- a/tests/TestPasskeys.cpp +++ b/tests/TestPasskeys.cpp @@ -273,7 +273,7 @@ void TestPasskeys::testCreatingAttestationObjectWithEC() const auto publicKeyCredentialOptions = browserMessageBuilder()->getJsonObject(PublicKeyCredentialOptions.toUtf8()); QJsonObject credentialCreationOptions; browserPasskeysClient()->getCredentialCreationOptions( - publicKeyCredentialOptions, QString("https://webauthn.io"), &credentialCreationOptions); + publicKeyCredentialOptions, QString("https://webauthn.io"), {}, &credentialCreationOptions); auto rpIdHash = browserMessageBuilder()->getSha256HashAsBase64(QString("webauthn.io")); QCOMPARE(rpIdHash, QString("dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvA")); @@ -346,7 +346,7 @@ void TestPasskeys::testCreatingAttestationObjectWithRSA() const auto publicKeyCredentialOptions = browserMessageBuilder()->getJsonObject(PublicKeyCredentialOptions.toUtf8()); QJsonObject credentialCreationOptions; browserPasskeysClient()->getCredentialCreationOptions( - publicKeyCredentialOptions, QString("https://webauthn.io"), &credentialCreationOptions); + publicKeyCredentialOptions, QString("https://webauthn.io"), {}, &credentialCreationOptions); credentialCreationOptions["credTypesAndPubKeyAlgs"] = pubKeyCredParams; auto rpIdHash = browserMessageBuilder()->getSha256HashAsBase64(QString("webauthn.io")); @@ -397,7 +397,7 @@ void TestPasskeys::testRegister() QJsonObject credentialCreationOptions; const auto creationResult = browserPasskeysClient()->getCredentialCreationOptions( - publicKeyCredentialOptions, origin, &credentialCreationOptions); + publicKeyCredentialOptions, origin, {}, &credentialCreationOptions); QVERIFY(creationResult == 0); TestingVariables testingVariables = {predefinedId, QString(), QString(), predefinedData}; @@ -424,6 +424,22 @@ void TestPasskeys::testRegister() QCOMPARE(clientDataJsonObject["type"], QString("webauthn.create")); } +void TestPasskeys::testRegisterWithRelatedOrigins() +{ + const auto origin = QString("https://webauthn.io"); + + // Modify the RP ID not to match with the origin. Actual accepted origin is inside Related Origins list. + const auto credentialOptions = QString(PublicKeyCredentialOptions).replace("webauthn.io", "example.io"); + const auto publicKeyCredentialOptions = browserMessageBuilder()->getJsonObject(credentialOptions.toUtf8()); + + // Only check that the creation succeeds with the Related Origins list + const auto relatedOrigins = QStringList({"https://webauthn.io"}); + QJsonObject credentialCreationOptions; + const auto creationResult = browserPasskeysClient()->getCredentialCreationOptions( + publicKeyCredentialOptions, origin, relatedOrigins, &credentialCreationOptions); + QVERIFY(creationResult == 0); +} + void TestPasskeys::testGet() { #if BOTAN_VERSION_CODE < BOTAN_VERSION_CODE_FOR(2, 14, 0) @@ -441,7 +457,7 @@ void TestPasskeys::testGet() QJsonObject assertionOptions; const auto assertionResult = - browserPasskeysClient()->getAssertionOptions(publicKeyCredentialRequestOptions, origin, &assertionOptions); + browserPasskeysClient()->getAssertionOptions(publicKeyCredentialRequestOptions, origin, {}, &assertionOptions); QVERIFY(assertionResult == 0); auto publicKeyCredential = browserPasskeys()->buildGetPublicKeyCredential(assertionOptions, id, {}, privateKeyPem); @@ -464,6 +480,22 @@ void TestPasskeys::testGet() QCOMPARE(clientDataJsonObject["challenge"].toString(), publicKeyCredentialRequestOptions["challenge"].toString()); } +void TestPasskeys::testGetWithRelatedOrigins() +{ + const auto origin = QString("https://webauthn.io"); + + // Modify the RP ID not to match with the origin. Actual accepted origin is inside Related Origins list. + const auto credentialOptions = QString(PublicKeyCredentialRequestOptions).replace("webauthn.io", "example.io"); + const auto publicKeyCredentialRequestOptions = browserMessageBuilder()->getJsonObject(credentialOptions.toUtf8()); + + // Only check that the assertion succeeds with the Related Origins list + const auto relatedOrigins = QStringList({"https://webauthn.io"}); + QJsonObject assertionOptions; + const auto assertionResult = browserPasskeysClient()->getAssertionOptions( + publicKeyCredentialRequestOptions, origin, relatedOrigins, &assertionOptions); + QVERIFY(assertionResult == 0); +} + void TestPasskeys::testExtensions() { auto extensions = QJsonObject({{"credProps", true}, {"uvm", true}}); @@ -633,6 +665,52 @@ void TestPasskeys::testRpIdValidation() QCOMPARE(differentDomain, ERROR_PASSKEYS_DOMAIN_RPID_MISMATCH); } +void TestPasskeys::testRelatedOriginsValidation() +{ + // A valid case. Matches with https://accountscenter.facebook.com when RP ID is e.g. accounts.meta.com. + QString origin = "https://accountscenter.facebook.com"; + const QStringList relatedOrigins = {"https://messenger.com", + "https://www.messenger.com", + "https://facebook.com", + "https://web.facebook.com", + "https://www.facebook.com", + "https://m.facebook.com", + "https://business.facebook.com", + "https://accountscenter.facebook.com", + "https://accounts.meta.com", + "https://accountscenter.meta.com"}; + QVERIFY(passkeyUtils()->validateRelatedOrigins(relatedOrigins, origin)); + + // Failed case. The origin differs from all related origins. + origin = "https://accountscenter.example.com"; + QVERIFY(!passkeyUtils()->validateRelatedOrigins(relatedOrigins, origin)); + + // Failed case where MAX_SEEN_LABELS has been met (too many different labels) + const QStringList failedRelatedOrigins = { + "https://messenge1r.com", + "https://www.messenger2.com", + "https://facebook3.com", + "https://web.facebook4.com", + "https://www.facebook5.com", + "https://m.facebook6.com", + "https://business.facebook7.com", + "https://accountscenter.meta.com" + "https://accounts.meta1.com", + "https://accounts.meta2.com", + "https://accounts.met3a.com", + "https://accounts.meta4.com", + "https://accounts.meta5.com", + "https://accounts.meta6.com", + "https://accounts.meta7.com", + "https://accounts.meta8.com", + "https://accounts.meta9.com", + "https://accounts.meta10.com", + "https://accounts.meta11.com", + "https://accountscenter.facebook.com", + }; + QVERIFY(!passkeyUtils()->validateRelatedOrigins(failedRelatedOrigins, origin)); +} + void TestPasskeys::testParseAttestation() { QVERIFY(passkeyUtils()->parseAttestation(QString("")) == QString("none")); diff --git a/tests/TestPasskeys.h b/tests/TestPasskeys.h index b1c8dbc4e4..e4f21ac676 100644 --- a/tests/TestPasskeys.h +++ b/tests/TestPasskeys.h @@ -1,5 +1,5 @@ /* - * Copyright (C) 2024 KeePassXC Team + * Copyright (C) 2026 KeePassXC Team * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -38,7 +38,9 @@ private slots: void testCreatingAttestationObjectWithEC(); void testCreatingAttestationObjectWithRSA(); void testRegister(); + void testRegisterWithRelatedOrigins(); void testGet(); + void testGetWithRelatedOrigins(); void testExtensions(); void testParseFlags(); @@ -48,6 +50,7 @@ private slots: void testIsDomain(); void testRegistrableDomainSuffix(); void testRpIdValidation(); + void testRelatedOriginsValidation(); void testParseAttestation(); void testParseCredentialTypes(); void testIsAuthenticatorSelectionValid(); diff --git a/tests/TestUrlTools.cpp b/tests/TestUrlTools.cpp index ae059d2287..5452a57106 100644 --- a/tests/TestUrlTools.cpp +++ b/tests/TestUrlTools.cpp @@ -1,5 +1,5 @@ /* - * Copyright (C) 2025 KeePassXC Team + * Copyright (C) 2026 KeePassXC Team * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -70,6 +70,18 @@ void TestUrlTools::testTopLevelDomain() } } +void TestUrlTools::testTopLevelDomainWithReturnOnlyLabel() +{ + QList> urls{ + {QString("https://another.example.co.uk"), QString("example")}, + {QString("https://www.example.com"), QString("example")}, + }; + + for (const auto& u : urls) { + QCOMPARE(urlTools()->getBaseDomainFromUrl(u.first, true), u.second); + } +} + void TestUrlTools::testIsIpAddress() { auto host1 = "example.com"; // Not valid diff --git a/tests/TestUrlTools.h b/tests/TestUrlTools.h index c2ba770b88..137df370e9 100644 --- a/tests/TestUrlTools.h +++ b/tests/TestUrlTools.h @@ -1,5 +1,5 @@ /* - * Copyright (C) 2025 KeePassXC Team + * Copyright (C) 2026 KeePassXC Team * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -31,6 +31,7 @@ private slots: void init(); void testTopLevelDomain(); + void testTopLevelDomainWithReturnOnlyLabel(); void testIsIpAddress(); void testIsUrlIdentical(); void testIsUrlValid();