Skip to content

Commit 9511a5d

Browse files
varjolintudroidmonkey
authored andcommitted
Add support for Related Origin Requests with passkeys
1 parent ada379f commit 9511a5d

17 files changed

Lines changed: 226 additions & 42 deletions

src/browser/BrowserAction.cpp

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (C) 2024 KeePassXC Team <team@keepassxc.org>
2+
* Copyright (C) 2026 KeePassXC Team <team@keepassxc.org>
33
*
44
* This program is free software: you can redistribute it and/or modify
55
* 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
546546
return getErrorReply(action, ERROR_PASSKEYS_INVALID_URL_PROVIDED);
547547
}
548548

549+
const auto relatedOrigins =
550+
browserMessageBuilder()->getStringListFromJsonArray(browserRequest.getArray("relatedOrigins"));
549551
const auto keyList = getConnectionKeys(browserRequest);
550-
const auto response = browserService()->showPasskeysAuthenticationPrompt(publicKey, origin, keyList);
552+
const auto response =
553+
browserService()->showPasskeysAuthenticationPrompt(publicKey, origin, relatedOrigins, keyList);
551554

552555
const Parameters params{{"response", response}};
553556
return buildResponse(action, browserRequest.incrementedNonce, params);
@@ -580,8 +583,11 @@ QJsonObject BrowserAction::handlePasskeysRegister(const QJsonObject& json, const
580583
}
581584

582585
const auto groupName = browserRequest.getString("groupName");
586+
const auto relatedOrigins =
587+
browserMessageBuilder()->getStringListFromJsonArray(browserRequest.getArray("relatedOrigins"));
583588
const auto keyList = getConnectionKeys(browserRequest);
584-
const auto response = browserService()->showPasskeysRegisterPrompt(publicKey, origin, groupName, keyList);
589+
const auto response =
590+
browserService()->showPasskeysRegisterPrompt(publicKey, origin, relatedOrigins, groupName, keyList);
585591

586592
const Parameters params{{"response", response}};
587593
return buildResponse(action, browserRequest.incrementedNonce, params);

src/browser/BrowserMessageBuilder.cpp

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
2+
* Copyright (C) 2026 KeePassXC Team <team@keepassxc.org>
33
*
44
* This program is free software: you can redistribute it and/or modify
55
* it under the terms of the GNU General Public License as published by
@@ -370,3 +370,15 @@ QString BrowserMessageBuilder::getSha256HashAsBase64(const QString& str) const
370370
{
371371
return getBase64FromArray(QCryptographicHash::hash(str.toUtf8(), QCryptographicHash::Sha256));
372372
}
373+
374+
QStringList BrowserMessageBuilder::getStringListFromJsonArray(const QJsonArray& jsonArray) const
375+
{
376+
QStringList stringList;
377+
for (const auto& item : jsonArray) {
378+
if (item.isString()) {
379+
stringList << item.toString();
380+
}
381+
}
382+
383+
return stringList;
384+
}

src/browser/BrowserMessageBuilder.h

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (C) 2024 KeePassXC Team <team@keepassxc.org>
2+
* Copyright (C) 2026 KeePassXC Team <team@keepassxc.org>
33
*
44
* This program is free software: you can redistribute it and/or modify
55
* it under the terms of the GNU General Public License as published by
@@ -106,6 +106,7 @@ class BrowserMessageBuilder
106106
QByteArray getArrayFromBase64(const QString& base64str) const;
107107
QByteArray getSha256Hash(const QString& str) const;
108108
QString getSha256HashAsBase64(const QString& str) const;
109+
QStringList getStringListFromJsonArray(const QJsonArray& jsonArray) const;
109110

110111
private:
111112
Q_DISABLE_COPY(BrowserMessageBuilder);

src/browser/BrowserPasskeysClient.cpp

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (C) 2024 KeePassXC Team <team@keepassxc.org>
2+
* Copyright (C) 2026 KeePassXC Team <team@keepassxc.org>
33
*
44
* This program is free software: you can redistribute it and/or modify
55
* it under the terms of the GNU General Public License as published by
@@ -33,6 +33,7 @@ BrowserPasskeysClient* BrowserPasskeysClient::instance()
3333
// https://www.w3.org/TR/2019/REC-webauthn-1-20190304/#createCredential
3434
int BrowserPasskeysClient::getCredentialCreationOptions(const QJsonObject& publicKeyOptions,
3535
const QString& origin,
36+
const QStringList& relatedOrigins,
3637
QJsonObject* result) const
3738
{
3839
if (!result || publicKeyOptions.isEmpty()) {
@@ -41,23 +42,26 @@ int BrowserPasskeysClient::getCredentialCreationOptions(const QJsonObject& publi
4142

4243
// Check validity of some basic values
4344
const auto checkResultError = passkeyUtils()->checkLimits(publicKeyOptions);
44-
if (checkResultError > 0) {
45+
if (checkResultError != PASSKEYS_SUCCESS) {
4546
return checkResultError;
4647
}
4748

4849
// Get effective domain
4950
QString effectiveDomain;
5051
const auto effectiveDomainResponse = passkeyUtils()->getEffectiveDomain(origin, &effectiveDomain);
51-
if (effectiveDomainResponse > 0) {
52+
if (effectiveDomainResponse != PASSKEYS_SUCCESS) {
5253
return effectiveDomainResponse;
5354
}
5455

5556
// Validate RP ID
5657
QString rpId;
5758
const auto rpName = publicKeyOptions["rp"]["name"].toString();
5859
const auto rpIdResponse = passkeyUtils()->validateRpId(publicKeyOptions["rp"]["id"], effectiveDomain, &rpId);
59-
if (rpIdResponse > 0) {
60-
return rpIdResponse;
60+
if (rpIdResponse != PASSKEYS_SUCCESS) {
61+
// Validate Related Origin Requests if found
62+
if (relatedOrigins.isEmpty() || !passkeyUtils()->validateRelatedOrigins(relatedOrigins, origin)) {
63+
return rpIdResponse;
64+
}
6165
}
6266

6367
// Check PublicKeyCredentialTypes
@@ -126,6 +130,7 @@ int BrowserPasskeysClient::getCredentialCreationOptions(const QJsonObject& publi
126130
// https://www.w3.org/TR/2019/REC-webauthn-1-20190304/#getAssertion
127131
int BrowserPasskeysClient::getAssertionOptions(const QJsonObject& publicKeyOptions,
128132
const QString& origin,
133+
const QStringList& relatedOrigins,
129134
QJsonObject* result) const
130135
{
131136
if (!result || publicKeyOptions.isEmpty()) {
@@ -135,15 +140,18 @@ int BrowserPasskeysClient::getAssertionOptions(const QJsonObject& publicKeyOptio
135140
// Get effective domain
136141
QString effectiveDomain;
137142
const auto effectiveDomainResponse = passkeyUtils()->getEffectiveDomain(origin, &effectiveDomain);
138-
if (effectiveDomainResponse > 0) {
143+
if (effectiveDomainResponse != PASSKEYS_SUCCESS) {
139144
return effectiveDomainResponse;
140145
}
141146

142147
// Validate RP ID
143148
QString rpId;
144149
const auto rpIdResponse = passkeyUtils()->validateRpId(publicKeyOptions["rpId"], effectiveDomain, &rpId);
145-
if (rpIdResponse > 0) {
146-
return rpIdResponse;
150+
if (rpIdResponse != PASSKEYS_SUCCESS) {
151+
// Validate Related Origin Requests if found
152+
if (relatedOrigins.isEmpty() || !passkeyUtils()->validateRelatedOrigins(relatedOrigins, origin)) {
153+
return rpIdResponse;
154+
}
147155
}
148156

149157
// Extensions

src/browser/BrowserPasskeysClient.h

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (C) 2024 KeePassXC Team <team@keepassxc.org>
2+
* Copyright (C) 2026 KeePassXC Team <team@keepassxc.org>
33
*
44
* This program is free software: you can redistribute it and/or modify
55
* it under the terms of the GNU General Public License as published by
@@ -31,9 +31,14 @@ class BrowserPasskeysClient : public QObject
3131
~BrowserPasskeysClient() = default;
3232
static BrowserPasskeysClient* instance();
3333

34-
int
35-
getCredentialCreationOptions(const QJsonObject& publicKeyOptions, const QString& origin, QJsonObject* result) const;
36-
int getAssertionOptions(const QJsonObject& publicKeyOptions, const QString& origin, QJsonObject* result) const;
34+
int getCredentialCreationOptions(const QJsonObject& publicKeyOptions,
35+
const QString& origin,
36+
const QStringList& relatedOrigins,
37+
QJsonObject* result) const;
38+
int getAssertionOptions(const QJsonObject& publicKeyOptions,
39+
const QString& origin,
40+
const QStringList& relatedOrigins,
41+
QJsonObject* result) const;
3742

3843
private:
3944
Q_DISABLE_COPY(BrowserPasskeysClient);

src/browser/BrowserService.cpp

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
2+
* Copyright (C) 2026 KeePassXC Team <team@keepassxc.org>
33
* Copyright (C) 2017 Sami Vänttinen <sami.vanttinen@protonmail.com>
44
* Copyright (C) 2013 Francois Ferrand
55
*
@@ -638,6 +638,7 @@ QString BrowserService::getKey(const QString& id)
638638
// Passkey registration
639639
QJsonObject BrowserService::showPasskeysRegisterPrompt(const QJsonObject& publicKeyOptions,
640640
const QString& origin,
641+
const QStringList& relatedOrigins,
641642
const QString& groupName,
642643
const StringPairList& keyList)
643644
{
@@ -647,9 +648,9 @@ QJsonObject BrowserService::showPasskeysRegisterPrompt(const QJsonObject& public
647648
}
648649

649650
QJsonObject credentialCreationOptions;
650-
const auto pkOptionsResult =
651-
browserPasskeysClient()->getCredentialCreationOptions(publicKeyOptions, origin, &credentialCreationOptions);
652-
if (pkOptionsResult > 0 || credentialCreationOptions.isEmpty()) {
651+
const auto pkOptionsResult = browserPasskeysClient()->getCredentialCreationOptions(
652+
publicKeyOptions, origin, relatedOrigins, &credentialCreationOptions);
653+
if (pkOptionsResult != PASSKEYS_SUCCESS || credentialCreationOptions.isEmpty()) {
653654
return getPasskeyError(pkOptionsResult);
654655
}
655656

@@ -737,6 +738,7 @@ QJsonObject BrowserService::showPasskeysRegisterPrompt(const QJsonObject& public
737738
// Passkey authentication
738739
QJsonObject BrowserService::showPasskeysAuthenticationPrompt(const QJsonObject& publicKeyOptions,
739740
const QString& origin,
741+
const QStringList& relatedOrigins,
740742
const StringPairList& keyList)
741743
{
742744
auto db = getDatabase();
@@ -746,8 +748,8 @@ QJsonObject BrowserService::showPasskeysAuthenticationPrompt(const QJsonObject&
746748

747749
QJsonObject assertionOptions;
748750
const auto assertionResult =
749-
browserPasskeysClient()->getAssertionOptions(publicKeyOptions, origin, &assertionOptions);
750-
if (assertionResult > 0 || assertionOptions.isEmpty()) {
751+
browserPasskeysClient()->getAssertionOptions(publicKeyOptions, origin, relatedOrigins, &assertionOptions);
752+
if (assertionResult != PASSKEYS_SUCCESS || assertionOptions.isEmpty()) {
751753
return getPasskeyError(assertionResult);
752754
}
753755

src/browser/BrowserService.h

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
2+
* Copyright (C) 2026 KeePassXC Team <team@keepassxc.org>
33
* Copyright (C) 2017 Sami Vänttinen <sami.vanttinen@protonmail.com>
44
* Copyright (C) 2013 Francois Ferrand
55
*
@@ -90,10 +90,12 @@ class BrowserService : public QObject
9090
#ifdef WITH_XC_BROWSER_PASSKEYS
9191
QJsonObject showPasskeysRegisterPrompt(const QJsonObject& publicKeyOptions,
9292
const QString& origin,
93+
const QStringList& relatedOrigins,
9394
const QString& groupName,
9495
const StringPairList& keyList);
9596
QJsonObject showPasskeysAuthenticationPrompt(const QJsonObject& publicKeyOptions,
9697
const QString& origin,
98+
const QStringList& relatedOrigins,
9799
const StringPairList& keyList);
98100
void addPasskeyToGroup(const QSharedPointer<Database>& db,
99101
Group* group,

src/browser/PasskeyUtils.cpp

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
2+
* Copyright (C) 2026 KeePassXC Team <team@keepassxc.org>
33
*
44
* This program is free software: you can redistribute it and/or modify
55
* it under the terms of the GNU General Public License as published by
@@ -25,6 +25,8 @@
2525
#include <QList>
2626
#include <QUrl>
2727

28+
#define MAX_SEEN_LABELS 10
29+
2830
Q_GLOBAL_STATIC(PasskeyUtils, s_passkeyUtils);
2931

3032
PasskeyUtils* PasskeyUtils::instance()
@@ -135,6 +137,37 @@ int PasskeyUtils::validateRpId(const QJsonValue& rpIdValue, const QString& effec
135137
return PASSKEYS_SUCCESS;
136138
}
137139

140+
// The steps for validation: https://www.w3.org/TR/webauthn-3/#sctn-validating-relation-origin
141+
bool PasskeyUtils::validateRelatedOrigins(const QStringList& relatedOrigins, const QString& origin) const
142+
{
143+
QSet<QString> labelsSeen;
144+
145+
for (const auto& originItem : relatedOrigins) {
146+
QString effectiveDomain;
147+
if (passkeyUtils()->getEffectiveDomain(originItem, &effectiveDomain) != PASSKEYS_SUCCESS) {
148+
continue;
149+
}
150+
151+
const auto label = urlTools()->getBaseDomainFromUrl(originItem, true);
152+
if (label.isNull() || label.isEmpty()) {
153+
continue;
154+
}
155+
156+
if (labelsSeen.size() >= MAX_SEEN_LABELS && !labelsSeen.contains(label)) {
157+
continue;
158+
}
159+
160+
if (originItem == origin) {
161+
return true;
162+
}
163+
164+
if (labelsSeen.size() < MAX_SEEN_LABELS) {
165+
labelsSeen.insert(label);
166+
}
167+
}
168+
return false;
169+
}
170+
138171
// https://www.w3.org/TR/2021/REC-webauthn-2-20210408/#dom-publickeycredentialcreationoptions-attestation
139172
QString PasskeyUtils::parseAttestation(const QString& attestation) const
140173
{

src/browser/PasskeyUtils.h

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
2+
* Copyright (C) 2026 KeePassXC Team <team@keepassxc.org>
33
*
44
* This program is free software: you can redistribute it and/or modify
55
* it under the terms of the GNU General Public License as published by
@@ -50,6 +50,7 @@ class PasskeyUtils : public QObject
5050
bool checkCredentialAssertionOptions(const QJsonObject& assertionOptions) const;
5151
int getEffectiveDomain(const QString& origin, QString* result) const;
5252
int validateRpId(const QJsonValue& rpIdValue, const QString& effectiveDomain, QString* result) const;
53+
bool validateRelatedOrigins(const QStringList& relatedOrigins, const QString& origin) const;
5354
QString parseAttestation(const QString& attestation) const;
5455
QJsonArray parseCredentialTypes(const QJsonArray& credentialTypes) const;
5556
bool isAuthenticatorSelectionValid(const QJsonObject& authenticatorSelection) const;

src/gui/UrlTools.cpp

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
2+
* Copyright (C) 2026 KeePassXC Team <team@keepassxc.org>
33
*
44
* This program is free software: you can redistribute it and/or modify
55
* it under the terms of the GNU General Public License as published by
@@ -53,10 +53,13 @@ QUrl UrlTools::getRedirectTarget(QNetworkReply* reply) const
5353
/**
5454
* Gets the base domain of URL or hostname.
5555
*
56+
* If returnOnlyLabel is true, return only the Registrable Origin Label:
57+
* https://www.w3.org/TR/webauthn-3/#registrable-origin-label
58+
*
5659
* Returns the base domain, e.g. https://another.example.co.uk -> example.co.uk
5760
* Up-to-date list can be found: https://publicsuffix.org/list/public_suffix_list.dat
5861
*/
59-
QString UrlTools::getBaseDomainFromUrl(const QString& url) const
62+
QString UrlTools::getBaseDomainFromUrl(const QString& url, bool returnOnlyLabel) const
6063
{
6164
auto qUrl = QUrl::fromUserInput(url);
6265

@@ -74,8 +77,11 @@ QString UrlTools::getBaseDomainFromUrl(const QString& url) const
7477
host.chop(tld.length() + 1);
7578
// Split the URL and select the last part, e.g. https://another.example -> example
7679
QString baseDomain = host.split('.').last();
77-
// Append the top level domain back to the URL, e.g. example -> example.co.uk
78-
baseDomain.append(QString(".%1").arg(tld));
80+
81+
if (!returnOnlyLabel) {
82+
// Append the top level domain back to the URL, e.g. example -> example.co.uk
83+
baseDomain.append(QString(".%1").arg(tld));
84+
}
7985

8086
return baseDomain;
8187
}

0 commit comments

Comments
 (0)