Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions src/browser/BrowserAction.cpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2024 KeePassXC Team <team@keepassxc.org>
* Copyright (C) 2026 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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
14 changes: 13 additions & 1 deletion src/browser/BrowserMessageBuilder.cpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
* Copyright (C) 2026 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
Expand Down Expand Up @@ -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;
}
3 changes: 2 additions & 1 deletion src/browser/BrowserMessageBuilder.h
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2024 KeePassXC Team <team@keepassxc.org>
* Copyright (C) 2026 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
Expand Down Expand Up @@ -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);
Expand Down
24 changes: 16 additions & 8 deletions src/browser/BrowserPasskeysClient.cpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2024 KeePassXC Team <team@keepassxc.org>
* Copyright (C) 2026 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
Expand Down Expand Up @@ -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()) {
Expand All @@ -41,23 +42,26 @@ 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;
}

// Validate RP ID
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
Expand Down Expand Up @@ -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()) {
Expand All @@ -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
Expand Down
13 changes: 9 additions & 4 deletions src/browser/BrowserPasskeysClient.h
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2024 KeePassXC Team <team@keepassxc.org>
* Copyright (C) 2026 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
Expand Down Expand Up @@ -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);
Expand Down
12 changes: 7 additions & 5 deletions src/browser/BrowserService.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand All @@ -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);
}

Expand Down Expand Up @@ -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();
Expand All @@ -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);
}

Expand Down
4 changes: 3 additions & 1 deletion src/browser/BrowserService.h
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
* Copyright (C) 2026 KeePassXC Team <team@keepassxc.org>
* Copyright (C) 2017 Sami Vänttinen <sami.vanttinen@protonmail.com>
* Copyright (C) 2013 Francois Ferrand
*
Expand Down Expand Up @@ -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<Database>& db,
Group* group,
Expand Down
35 changes: 34 additions & 1 deletion src/browser/PasskeyUtils.cpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
* Copyright (C) 2026 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
Expand All @@ -25,6 +25,8 @@
#include <QList>
#include <QUrl>

#define MAX_SEEN_LABELS 10

Q_GLOBAL_STATIC(PasskeyUtils, s_passkeyUtils);

PasskeyUtils* PasskeyUtils::instance()
Expand Down Expand Up @@ -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<QString> 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
{
Expand Down
3 changes: 2 additions & 1 deletion src/browser/PasskeyUtils.h
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
* Copyright (C) 2026 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
Expand Down Expand Up @@ -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;
Expand Down
14 changes: 10 additions & 4 deletions src/gui/UrlTools.cpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
* Copyright (C) 2026 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
Expand Down Expand Up @@ -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);

Expand All @@ -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;
}
Expand Down
4 changes: 2 additions & 2 deletions src/gui/UrlTools.h
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
* Copyright (C) 2026 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
Expand Down Expand Up @@ -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
Expand Down
15 changes: 14 additions & 1 deletion tests/TestBrowser.cpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
* Copyright (C) 2026 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
Expand All @@ -23,7 +23,9 @@
#include "core/Tools.h"
#include "crypto/Crypto.h"

#include <QJsonArray>
#include <QJsonObject>
#include <QStringList>
#include <QTest>

#include <botan/sodium.h>
Expand Down Expand Up @@ -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}};
Expand Down
Loading
Loading