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:
Sami Vänttinen 2024-03-06 14:42:01 +02:00 committed by GitHub
parent dff2f186ce
commit ac2b445db6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 1248 additions and 269 deletions

View File

@ -976,6 +976,10 @@ Do you want to overwrite the Passkey in %1 - %2?</source>
<source>KeePassXC - New key association request</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Passkey</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>BrowserSettingsWidget</name>
@ -1222,6 +1226,14 @@ Do you want to overwrite the Passkey in %1 - %2?</source>
<source>&lt;b&gt;Error:&lt;/b&gt; The installed proxy executable is missing from the expected location: %1&lt;br/&gt;Please set a custom proxy location in the advanced settings or reinstall the application.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Allows using insecure http://localhost with Passkeys for testing purposes.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Allow using localhost with Passkeys</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>CloneDialog</name>
@ -8419,10 +8431,6 @@ Kernel: %3 %4</source>
<source>Invalid URL provided</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Resident Keys are not supported</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Passkeys</source>
<translation type="unfinished"></translation>
@ -8483,6 +8491,38 @@ Kernel: %3 %4</source>
<source>Failed to decrypt key data.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Origin is empty or not allowed</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Effective domain is not a valid domain</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Origin and RP ID do not match</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>No supported algorithms were provided</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Wait for timer to expire</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Unknown Passkeys error</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Challenge is shorter than required minimum length</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>user.id does not match the required length</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>QtIOCompressor</name>

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
* 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
@ -19,6 +19,7 @@
#include "BrowserMessageBuilder.h"
#ifdef WITH_XC_BROWSER_PASSKEYS
#include "BrowserPasskeys.h"
#include "PasskeyUtils.h"
#endif
#include "BrowserSettings.h"
#include "core/Global.h"
@ -541,7 +542,7 @@ QJsonObject BrowserAction::handlePasskeysGet(const QJsonObject& json, const QStr
}
const auto origin = browserRequest.getString("origin");
if (!origin.startsWith("https://")) {
if (!passkeyUtils()->isOriginAllowedWithLocalhost(browserSettings()->allowLocalhostWithPasskeys(), origin)) {
return getErrorReply(action, ERROR_PASSKEYS_INVALID_URL_PROVIDED);
}
@ -574,8 +575,8 @@ QJsonObject BrowserAction::handlePasskeysRegister(const QJsonObject& json, const
}
const auto origin = browserRequest.getString("origin");
if (!origin.startsWith("https://")) {
return getErrorReply(action, ERROR_KEEPASS_ACTION_CANCELLED_OR_DENIED);
if (!passkeyUtils()->isOriginAllowedWithLocalhost(browserSettings()->allowLocalhostWithPasskeys(), origin)) {
return getErrorReply(action, ERROR_PASSKEYS_INVALID_URL_PROVIDED);
}
const auto keyList = getConnectionKeys(browserRequest);

View File

@ -44,6 +44,11 @@ struct BrowserRequest
return decrypted.value(param).toArray();
}
inline bool getBool(const QString& param) const
{
return decrypted.value(param).toBool();
}
inline QJsonObject getObject(const QString& param) const
{
return decrypted.value(param).toObject();

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
* 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
@ -48,6 +48,16 @@ QByteArray BrowserCbor::cborEncodeAttestation(const QByteArray& authData) const
// https://w3c.github.io/webauthn/#authdata-attestedcredentialdata-credentialpublickey
QByteArray BrowserCbor::cborEncodePublicKey(int alg, const QByteArray& first, const QByteArray& second) const
{
const auto keyType = getCoseKeyType(alg);
if (keyType == 0) {
return {};
}
const auto curveParameter = getCurveParameter(alg);
if ((alg == WebAuthnAlgorithms::ES256 || alg == WebAuthnAlgorithms::EDDSA) && curveParameter == 0) {
return {};
}
QByteArray result;
QCborStreamWriter writer(&result);
@ -56,7 +66,7 @@ QByteArray BrowserCbor::cborEncodePublicKey(int alg, const QByteArray& first, co
// Key type
writer.append(1);
writer.append(getCoseKeyType(alg));
writer.append(keyType);
// Signature algorithm
writer.append(3);
@ -64,7 +74,7 @@ QByteArray BrowserCbor::cborEncodePublicKey(int alg, const QByteArray& first, co
// Curve parameter
writer.append(-1);
writer.append(getCurveParameter(alg));
writer.append(curveParameter);
// Key x-coordinate
writer.append(-2);
@ -80,7 +90,7 @@ QByteArray BrowserCbor::cborEncodePublicKey(int alg, const QByteArray& first, co
// Key type
writer.append(1);
writer.append(getCoseKeyType(alg));
writer.append(keyType);
// Signature algorithm
writer.append(3);
@ -96,21 +106,24 @@ QByteArray BrowserCbor::cborEncodePublicKey(int alg, const QByteArray& first, co
writer.endMap();
} else if (alg == WebAuthnAlgorithms::EDDSA) {
// https://www.rfc-editor.org/rfc/rfc8152#section-13.2
writer.startMap(3);
writer.startMap(4);
// Key type
writer.append(1);
writer.append(keyType);
// Algorithm
writer.append(3);
writer.append(alg);
// Curve parameter
writer.append(-1);
writer.append(getCurveParameter(alg));
writer.append(curveParameter);
// Public key
writer.append(-2);
writer.append(first);
// Private key
writer.append(-4);
writer.append(second);
writer.endMap();
}
@ -230,7 +243,7 @@ unsigned int BrowserCbor::getCurveParameter(int alg) const
case WebAuthnAlgorithms::EDDSA:
return WebAuthnCurveKey::ED25519;
default:
return WebAuthnCurveKey::P256;
return WebAuthnCurveKey::INVALID_CURVE_KEY;
}
}
@ -240,14 +253,15 @@ unsigned int BrowserCbor::getCoseKeyType(int alg) const
{
switch (alg) {
case WebAuthnAlgorithms::ES256:
return WebAuthnCoseKeyType::EC2;
case WebAuthnAlgorithms::ES384:
case WebAuthnAlgorithms::ES512:
return WebAuthnCoseKeyType::EC2;
return WebAuthnCoseKeyType::INVALID_COSE_KEY_TYPE;
case WebAuthnAlgorithms::EDDSA:
return WebAuthnCoseKeyType::OKP;
case WebAuthnAlgorithms::RS256:
return WebAuthnCoseKeyType::RSA;
default:
return WebAuthnCoseKeyType::EC2;
return WebAuthnCoseKeyType::INVALID_COSE_KEY_TYPE;
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
* 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
@ -35,6 +35,7 @@ enum WebAuthnAlgorithms : int
// https://www.rfc-editor.org/rfc/rfc9053#section-7.1
enum WebAuthnCurveKey : int
{
INVALID_CURVE_KEY = 0,
P256 = 1, // EC2, NIST P-256, also known as secp256r1
P384 = 2, // EC2, NIST P-384, also known as secp384r1
P521 = 3, // EC2, NIST P-521, also known as secp521r1
@ -48,6 +49,7 @@ enum WebAuthnCurveKey : int
// For RSA: https://www.rfc-editor.org/rfc/rfc8230#section-4
enum WebAuthnCoseKeyType : int
{
INVALID_COSE_KEY_TYPE = 0,
OKP = 1, // Octet Keypair
EC2 = 2, // Elliptic Curve
RSA = 3 // RSA

View File

@ -140,8 +140,22 @@ QString BrowserMessageBuilder::getErrorMessage(const int errorCode) const
return QObject::tr("Empty public key");
case ERROR_PASSKEYS_INVALID_URL_PROVIDED:
return QObject::tr("Invalid URL provided");
case ERROR_PASSKEYS_RESIDENT_KEYS_NOT_SUPPORTED:
return QObject::tr("Resident Keys are not supported");
case ERROR_PASSKEYS_ORIGIN_NOT_ALLOWED:
return QObject::tr("Origin is empty or not allowed");
case ERROR_PASSKEYS_DOMAIN_IS_NOT_VALID:
return QObject::tr("Effective domain is not a valid domain");
case ERROR_PASSKEYS_DOMAIN_RPID_MISMATCH:
return QObject::tr("Origin and RP ID do not match");
case ERROR_PASSKEYS_NO_SUPPORTED_ALGORITHMS:
return QObject::tr("No supported algorithms were provided");
case ERROR_PASSKEYS_WAIT_FOR_LIFETIMER:
return QObject::tr("Wait for timer to expire");
case ERROR_PASSKEYS_UNKNOWN_ERROR:
return QObject::tr("Unknown Passkeys error");
case ERROR_PASSKEYS_INVALID_CHALLENGE:
return QObject::tr("Challenge is shorter than required minimum length");
case ERROR_PASSKEYS_INVALID_USER_ID:
return QObject::tr("user.id does not match the required length");
default:
return QObject::tr("Unknown error");
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
* 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
@ -55,7 +55,14 @@ namespace
ERROR_PASSKEYS_INVALID_USER_VERIFICATION = 23,
ERROR_PASSKEYS_EMPTY_PUBLIC_KEY = 24,
ERROR_PASSKEYS_INVALID_URL_PROVIDED = 25,
ERROR_PASSKEYS_RESIDENT_KEYS_NOT_SUPPORTED = 26,
ERROR_PASSKEYS_ORIGIN_NOT_ALLOWED = 26,
ERROR_PASSKEYS_DOMAIN_IS_NOT_VALID = 27,
ERROR_PASSKEYS_DOMAIN_RPID_MISMATCH = 28,
ERROR_PASSKEYS_NO_SUPPORTED_ALGORITHMS = 29,
ERROR_PASSKEYS_WAIT_FOR_LIFETIMER = 30,
ERROR_PASSKEYS_UNKNOWN_ERROR = 31,
ERROR_PASSKEYS_INVALID_CHALLENGE = 32,
ERROR_PASSKEYS_INVALID_USER_ID = 33,
};
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
* 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
@ -18,8 +18,8 @@
#include "BrowserPasskeys.h"
#include "BrowserMessageBuilder.h"
#include "BrowserService.h"
#include "PasskeyUtils.h"
#include "crypto/Random.h"
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QtEndian>
@ -40,6 +40,13 @@ Q_GLOBAL_STATIC(BrowserPasskeys, s_browserPasskeys);
// KeePassXC AAGUID: fdb141b2-5d84-443e-8a35-4698c205a502
const QString BrowserPasskeys::AAGUID = QStringLiteral("fdb141b25d84443e8a354698c205a502");
// Authenticator capabilities
const QString BrowserPasskeys::ATTACHMENT_CROSS_PLATFORM = QStringLiteral("cross-platform");
const QString BrowserPasskeys::ATTACHMENT_PLATFORM = QStringLiteral("platform");
const QString BrowserPasskeys::AUTHENTICATOR_TRANSPORT = QStringLiteral("internal");
const bool BrowserPasskeys::SUPPORT_RESIDENT_KEYS = true;
const bool BrowserPasskeys::SUPPORT_USER_VERIFICATION = true;
const QString BrowserPasskeys::PUBLIC_KEY = QStringLiteral("public-key");
const QString BrowserPasskeys::REQUIREMENT_DISCOURAGED = QStringLiteral("discouraged");
const QString BrowserPasskeys::REQUIREMENT_PREFERRED = QStringLiteral("preferred");
@ -49,7 +56,7 @@ const QString BrowserPasskeys::PASSKEYS_ATTESTATION_DIRECT = QStringLiteral("dir
const QString BrowserPasskeys::PASSKEYS_ATTESTATION_NONE = QStringLiteral("none");
const QString BrowserPasskeys::KPEX_PASSKEY_USERNAME = QStringLiteral("KPEX_PASSKEY_USERNAME");
const QString BrowserPasskeys::KPEX_PASSKEY_GENERATED_USER_ID = QStringLiteral("KPEX_PASSKEY_GENERATED_USER_ID");
const QString BrowserPasskeys::KPEX_PASSKEY_CREDENTIAL_ID = QStringLiteral("KPEX_PASSKEY_CREDENTIAL_ID");
const QString BrowserPasskeys::KPEX_PASSKEY_PRIVATE_KEY_PEM = QStringLiteral("KPEX_PASSKEY_PRIVATE_KEY_PEM");
const QString BrowserPasskeys::KPEX_PASSKEY_RELYING_PARTY = QStringLiteral("KPEX_PASSKEY_RELYING_PARTY");
const QString BrowserPasskeys::KPEX_PASSKEY_USER_HANDLE = QStringLiteral("KPEX_PASSKEY_USER_HANDLE");
@ -59,56 +66,80 @@ BrowserPasskeys* BrowserPasskeys::instance()
return s_browserPasskeys;
}
PublicKeyCredential BrowserPasskeys::buildRegisterPublicKeyCredential(const QJsonObject& publicKeyCredentialOptions,
const QString& origin,
PublicKeyCredential BrowserPasskeys::buildRegisterPublicKeyCredential(const QJsonObject& credentialCreationOptions,
const TestingVariables& testingVariables)
{
QJsonObject publicKeyCredential;
if (!passkeyUtils()->checkCredentialCreationOptions(credentialCreationOptions)) {
return {};
}
const auto authenticatorAttachment = credentialCreationOptions["authenticatorAttachment"];
const auto clientDataJson = credentialCreationOptions["clientDataJSON"].toObject();
const auto extensions = credentialCreationOptions["extensions"].toString();
const auto credentialId = testingVariables.credentialId.isEmpty()
? browserMessageBuilder()->getRandomBytesAsBase64(ID_BYTES)
: testingVariables.credentialId;
// Extensions
auto extensionObject = publicKeyCredentialOptions["extensions"].toObject();
const auto extensionData = buildExtensionData(extensionObject);
const auto extensions = browserMessageBuilder()->getBase64FromArray(extensionData);
// Credential private key
const auto alg = getAlgorithmFromPublicKey(credentialCreationOptions);
const auto privateKey = buildCredentialPrivateKey(alg, testingVariables.first, testingVariables.second);
if (privateKey.cborEncodedPublicKey.isEmpty() && privateKey.privateKeyPem.isEmpty()) {
// Key creation failed
return {};
}
// Attestation
const auto attestationObject = buildAttestationObject(
credentialCreationOptions, extensions, credentialId, privateKey.cborEncodedPublicKey, testingVariables);
if (attestationObject.isEmpty()) {
return {};
}
// Response
QJsonObject responseObject;
const auto clientData = buildClientDataJson(publicKeyCredentialOptions, origin, false);
const auto attestationObject =
buildAttestationObject(publicKeyCredentialOptions, extensions, credentialId, testingVariables);
responseObject["clientDataJSON"] = browserMessageBuilder()->getBase64FromJson(clientData);
responseObject["attestationObject"] = browserMessageBuilder()->getBase64FromArray(attestationObject.cborEncoded);
responseObject["attestationObject"] = browserMessageBuilder()->getBase64FromArray(attestationObject);
responseObject["clientDataJSON"] = browserMessageBuilder()->getBase64FromJson(clientDataJson);
// PublicKeyCredential
publicKeyCredential["authenticatorAttachment"] = QString("platform");
QJsonObject publicKeyCredential;
publicKeyCredential["authenticatorAttachment"] = authenticatorAttachment;
publicKeyCredential["id"] = credentialId;
publicKeyCredential["response"] = responseObject;
publicKeyCredential["type"] = PUBLIC_KEY;
return {credentialId, publicKeyCredential, attestationObject.pem};
PublicKeyCredential result;
result.credentialId = credentialId;
result.key = privateKey.privateKeyPem;
result.response = publicKeyCredential;
return result;
}
QJsonObject BrowserPasskeys::buildGetPublicKeyCredential(const QJsonObject& publicKeyCredentialRequestOptions,
const QString& origin,
QJsonObject BrowserPasskeys::buildGetPublicKeyCredential(const QJsonObject& assertionOptions,
const QString& credentialId,
const QString& userHandle,
const QString& privateKeyPem)
{
const auto authenticatorData = buildGetAttestationObject(publicKeyCredentialRequestOptions);
const auto clientData = buildClientDataJson(publicKeyCredentialRequestOptions, origin, true);
const auto clientDataArray = QJsonDocument(clientData).toJson(QJsonDocument::Compact);
if (!passkeyUtils()->checkCredentialAssertionOptions(assertionOptions)) {
return {};
}
const auto authenticatorData = buildAuthenticatorData(assertionOptions);
const auto clientDataJson = assertionOptions["clientDataJson"].toObject();
const auto clientDataArray = QJsonDocument(clientDataJson).toJson(QJsonDocument::Compact);
const auto signature = buildSignature(authenticatorData, clientDataArray, privateKeyPem);
if (signature.isEmpty()) {
return {};
}
QJsonObject responseObject;
responseObject["authenticatorData"] = browserMessageBuilder()->getBase64FromArray(authenticatorData);
responseObject["clientDataJSON"] = browserMessageBuilder()->getBase64FromArray(clientDataArray);
responseObject["clientDataJSON"] = browserMessageBuilder()->getBase64FromJson(clientDataJson);
responseObject["signature"] = browserMessageBuilder()->getBase64FromArray(signature);
responseObject["userHandle"] = userHandle;
QJsonObject publicKeyCredential;
publicKeyCredential["authenticatorAttachment"] = QString("platform");
publicKeyCredential["authenticatorAttachment"] = BrowserPasskeys::ATTACHMENT_PLATFORM;
publicKeyCredential["id"] = credentialId;
publicKeyCredential["response"] = responseObject;
publicKeyCredential["type"] = PUBLIC_KEY;
@ -116,68 +147,22 @@ QJsonObject BrowserPasskeys::buildGetPublicKeyCredential(const QJsonObject& publ
return publicKeyCredential;
}
bool BrowserPasskeys::isUserVerificationValid(const QString& userVerification) const
{
return QStringList({REQUIREMENT_PREFERRED, REQUIREMENT_REQUIRED, REQUIREMENT_DISCOURAGED})
.contains(userVerification);
}
// See https://w3c.github.io/webauthn/#sctn-createCredential for default timeout values when not set in the request
int BrowserPasskeys::getTimeout(const QString& userVerification, int timeout) const
{
if (timeout == 0) {
return userVerification == REQUIREMENT_DISCOURAGED ? DEFAULT_DISCOURAGED_TIMEOUT : DEFAULT_TIMEOUT;
}
return timeout;
}
QStringList BrowserPasskeys::getAllowedCredentialsFromPublicKey(const QJsonObject& publicKey) const
{
QStringList allowedCredentials;
for (const auto& cred : publicKey["allowCredentials"].toArray()) {
const auto c = cred.toObject();
const auto id = c["id"].toString();
if (c["type"].toString() == PUBLIC_KEY && !id.isEmpty()) {
allowedCredentials << id;
}
}
return allowedCredentials;
}
QJsonObject BrowserPasskeys::buildClientDataJson(const QJsonObject& publicKey, const QString& origin, bool get)
{
QJsonObject clientData;
clientData["challenge"] = publicKey["challenge"];
clientData["crossOrigin"] = false;
clientData["origin"] = origin;
clientData["type"] = get ? QString("webauthn.get") : QString("webauthn.create");
return clientData;
}
// https://w3c.github.io/webauthn/#attestation-object
PrivateKey BrowserPasskeys::buildAttestationObject(const QJsonObject& publicKey,
QByteArray BrowserPasskeys::buildAttestationObject(const QJsonObject& credentialCreationOptions,
const QString& extensions,
const QString& credentialId,
const QByteArray& cborEncodedPublicKey,
const TestingVariables& testingVariables)
{
QByteArray result;
// Create SHA256 hash from rpId
const auto rpIdHash = browserMessageBuilder()->getSha256Hash(publicKey["rp"]["id"].toString());
const auto rpIdHash = browserMessageBuilder()->getSha256Hash(credentialCreationOptions["rp"]["id"].toString());
result.append(rpIdHash);
// Use default flags
const auto flags =
setFlagsFromJson(QJsonObject({{"ED", !extensions.isEmpty()},
{"AT", true},
{"BS", false},
{"BE", false},
{"UV", publicKey["userVerification"].toString() != REQUIREMENT_DISCOURAGED},
{"UP", true}}));
const auto flags = setFlagsFromJson(QJsonObject(
{{"ED", !extensions.isEmpty()}, {"AT", true}, {"BS", false}, {"BE", false}, {"UV", true}, {"UP", true}}));
result.append(flags);
// Signature counter (not supported, always 0
@ -188,7 +173,7 @@ PrivateKey BrowserPasskeys::buildAttestationObject(const QJsonObject& publicKey,
result.append(browserMessageBuilder()->getArrayFromHexString(AAGUID));
// Credential length
const char credentialLength[2] = {0x00, 0x20};
const char credentialLength[2] = {0x00, ID_BYTES};
result.append(QByteArray::fromRawData(credentialLength, 2));
// Credential Id
@ -196,10 +181,8 @@ PrivateKey BrowserPasskeys::buildAttestationObject(const QJsonObject& publicKey,
testingVariables.credentialId.isEmpty() ? credentialId.toUtf8() : testingVariables.credentialId.toUtf8(),
QByteArray::Base64UrlEncoding));
// Credential private key
const auto alg = getAlgorithmFromPublicKey(publicKey);
const auto credentialPublicKey = buildCredentialPrivateKey(alg, testingVariables.first, testingVariables.second);
result.append(credentialPublicKey.cborEncoded);
// Credential public key
result.append(cborEncodedPublicKey);
// Add extension data if available
if (!extensions.isEmpty()) {
@ -207,35 +190,35 @@ PrivateKey BrowserPasskeys::buildAttestationObject(const QJsonObject& publicKey,
}
// The final result should be CBOR encoded
return {m_browserCbor.cborEncodeAttestation(result), credentialPublicKey.pem};
return m_browserCbor.cborEncodeAttestation(result);
}
// Build a short version of the attestation object for webauthn.get
QByteArray BrowserPasskeys::buildGetAttestationObject(const QJsonObject& publicKey)
QByteArray BrowserPasskeys::buildAuthenticatorData(const QJsonObject& publicKey)
{
QByteArray result;
const auto rpIdHash = browserMessageBuilder()->getSha256Hash(publicKey["rpId"].toString());
result.append(rpIdHash);
const auto flags =
setFlagsFromJson(QJsonObject({{"ED", false},
{"AT", false},
{"BS", false},
{"BE", false},
{"UV", publicKey["userVerification"].toString() != REQUIREMENT_DISCOURAGED},
{"UP", true}}));
const auto extensions = publicKey["extensions"].toString();
const auto flags = setFlagsFromJson(QJsonObject(
{{"ED", !extensions.isEmpty()}, {"AT", false}, {"BS", false}, {"BE", false}, {"UV", true}, {"UP", true}}));
result.append(flags);
// Signature counter (not supported, always 0
const char counter[4] = {0x00, 0x00, 0x00, 0x00};
result.append(QByteArray::fromRawData(counter, 4));
if (!extensions.isEmpty()) {
result.append(browserMessageBuilder()->getArrayFromBase64(extensions));
}
return result;
}
// See: https://w3c.github.io/webauthn/#sctn-encoded-credPubKey-examples
PrivateKey
AttestationKeyPair
BrowserPasskeys::buildCredentialPrivateKey(int alg, const QString& predefinedFirst, const QString& predefinedSecond)
{
// Only support -7, P256 (EC), -8 (EdDSA) and -257 (RSA) for now
@ -299,7 +282,14 @@ BrowserPasskeys::buildCredentialPrivateKey(int alg, const QString& predefinedFir
}
auto result = m_browserCbor.cborEncodePublicKey(alg, firstPart, secondPart);
return {result, pem};
if (result.isEmpty()) {
return {};
}
AttestationKeyPair attestationKeyPair;
attestationKeyPair.cborEncodedPublicKey = result;
attestationKeyPair.privateKeyPem = pem;
return attestationKeyPair;
}
QByteArray BrowserPasskeys::buildSignature(const QByteArray& authenticatorData,
@ -339,7 +329,8 @@ QByteArray BrowserPasskeys::buildSignature(const QByteArray& authenticatorData,
rawSignature = signer.signature(*randomGen()->getRng());
} else if (algName == "Ed25519") {
Botan::Ed25519_PrivateKey privateKey(algId, privateKeyBytes);
Botan::PK_Signer signer(privateKey, *randomGen()->getRng(), "SHA-512");
// "Pure" here means signing message directly. SHA-512 is only used with pre-hashed Ed25519 (Ed25519ph).
Botan::PK_Signer signer(privateKey, *randomGen()->getRng(), "Pure");
signer.update(reinterpret_cast<const uint8_t*>(attToBeSigned.constData()), attToBeSigned.size());
rawSignature = signer.signature(*randomGen()->getRng());
@ -356,26 +347,6 @@ QByteArray BrowserPasskeys::buildSignature(const QByteArray& authenticatorData,
}
}
QByteArray BrowserPasskeys::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 {};
}
// Parse authentication data byte array to JSON
// See: https://www.w3.org/TR/webauthn/images/fido-attestation-structures.svg
// And: https://w3c.github.io/webauthn/#attested-credential-data
@ -420,6 +391,9 @@ QJsonObject BrowserPasskeys::parseFlags(const QByteArray& flags) const
{"UP", flagBits.test(AuthenticatorFlags::UP)}});
}
// https://w3c.github.io/webauthn/#table-authData
// ED - Extension Data, AT - Attested Credential, BS - Reserved
// BE - Reserved , UV - User Verified, UP - User Present
char BrowserPasskeys::setFlagsFromJson(const QJsonObject& flags) const
{
if (flags.isEmpty()) {
@ -444,9 +418,9 @@ char BrowserPasskeys::setFlagsFromJson(const QJsonObject& flags) const
}
// Returns the first supported algorithm from the pubKeyCredParams list (only support ES256, RS256 and EdDSA for now)
WebAuthnAlgorithms BrowserPasskeys::getAlgorithmFromPublicKey(const QJsonObject& publicKey) const
WebAuthnAlgorithms BrowserPasskeys::getAlgorithmFromPublicKey(const QJsonObject& credentialCreationOptions) const
{
const auto pubKeyCredParams = publicKey["pubKeyCredParams"].toArray();
const auto pubKeyCredParams = credentialCreationOptions["credTypesAndPubKeyAlgs"].toArray();
if (!pubKeyCredParams.isEmpty()) {
const auto alg = pubKeyCredParams.first()["alg"].toInt();
if (alg == WebAuthnAlgorithms::ES256 || alg == WebAuthnAlgorithms::RS256 || alg == WebAuthnAlgorithms::EDDSA) {

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
* 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
@ -27,8 +27,6 @@
#define ID_BYTES 32
#define HASH_BYTES 32
#define DEFAULT_TIMEOUT 300000
#define DEFAULT_DISCOURAGED_TIMEOUT 120000
#define RSA_BITS 2048
#define RSA_EXPONENT 65537
@ -59,10 +57,10 @@ struct PublicKeyCredential
QByteArray key;
};
struct PrivateKey
struct AttestationKeyPair
{
QByteArray cborEncoded;
QByteArray pem;
QByteArray cborEncodedPublicKey;
QByteArray privateKeyPem;
};
// Predefined variables used for testing the class
@ -82,19 +80,21 @@ public:
~BrowserPasskeys() = default;
static BrowserPasskeys* instance();
PublicKeyCredential buildRegisterPublicKeyCredential(const QJsonObject& publicKeyCredentialOptions,
const QString& origin,
PublicKeyCredential buildRegisterPublicKeyCredential(const QJsonObject& credentialCreationOptions,
const TestingVariables& predefinedVariables = {});
QJsonObject buildGetPublicKeyCredential(const QJsonObject& publicKeyCredentialRequestOptions,
const QString& origin,
QJsonObject buildGetPublicKeyCredential(const QJsonObject& assertionOptions,
const QString& credentialId,
const QString& userHandle,
const QString& privateKeyPem);
bool isUserVerificationValid(const QString& userVerification) const;
int getTimeout(const QString& userVerification, int timeout) const;
QStringList getAllowedCredentialsFromPublicKey(const QJsonObject& publicKey) const;
static const QString AAGUID;
static const QString ATTACHMENT_CROSS_PLATFORM;
static const QString ATTACHMENT_PLATFORM;
static const QString AUTHENTICATOR_TRANSPORT;
static const bool SUPPORT_RESIDENT_KEYS;
static const bool SUPPORT_USER_VERIFICATION;
static const QString PUBLIC_KEY;
static const QString REQUIREMENT_DISCOURAGED;
static const QString REQUIREMENT_PREFERRED;
@ -104,28 +104,27 @@ public:
static const QString PASSKEYS_ATTESTATION_NONE;
static const QString KPEX_PASSKEY_USERNAME;
static const QString KPEX_PASSKEY_GENERATED_USER_ID;
static const QString KPEX_PASSKEY_CREDENTIAL_ID;
static const QString KPEX_PASSKEY_PRIVATE_KEY_PEM;
static const QString KPEX_PASSKEY_RELYING_PARTY;
static const QString KPEX_PASSKEY_USER_HANDLE;
private:
QJsonObject buildClientDataJson(const QJsonObject& publicKey, const QString& origin, bool get);
PrivateKey buildAttestationObject(const QJsonObject& publicKey,
QByteArray buildAttestationObject(const QJsonObject& credentialCreationOptions,
const QString& extensions,
const QString& credentialId,
const QByteArray& cborEncodedPublicKey,
const TestingVariables& predefinedVariables = {});
QByteArray buildGetAttestationObject(const QJsonObject& publicKey);
PrivateKey buildCredentialPrivateKey(int alg,
const QString& predefinedFirst = QString(),
const QString& predefinedSecond = QString());
QByteArray buildAuthenticatorData(const QJsonObject& publicKey);
AttestationKeyPair buildCredentialPrivateKey(int alg,
const QString& predefinedFirst = QString(),
const QString& predefinedSecond = QString());
QByteArray
buildSignature(const QByteArray& authenticatorData, const QByteArray& clientData, const QString& privateKeyPem);
QByteArray buildExtensionData(QJsonObject& extensionObject) const;
QJsonObject parseAuthData(const QByteArray& authData) const;
QJsonObject parseFlags(const QByteArray& flags) const;
char setFlagsFromJson(const QJsonObject& flags) const;
WebAuthnAlgorithms getAlgorithmFromPublicKey(const QJsonObject& publicKey) const;
WebAuthnAlgorithms getAlgorithmFromPublicKey(const QJsonObject& credentialCreationOptions) const;
QByteArray bigIntToQByteArray(Botan::BigInt& bigInt) const;
Q_DISABLE_COPY(BrowserPasskeys);

View File

@ -0,0 +1,173 @@
/*
* 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 "BrowserPasskeysClient.h"
#include "BrowserMessageBuilder.h"
#include "BrowserPasskeys.h"
#include "PasskeyUtils.h"
#include <QJsonDocument>
Q_GLOBAL_STATIC(BrowserPasskeysClient, s_browserPasskeysClient);
BrowserPasskeysClient* BrowserPasskeysClient::instance()
{
return s_browserPasskeysClient;
}
// Constructs CredentialCreationOptions from the original PublicKeyCredential
// https://www.w3.org/TR/2019/REC-webauthn-1-20190304/#createCredential
int BrowserPasskeysClient::getCredentialCreationOptions(const QJsonObject& publicKeyOptions,
const QString& origin,
QJsonObject* result) const
{
if (!result || publicKeyOptions.isEmpty()) {
return ERROR_PASSKEYS_EMPTY_PUBLIC_KEY;
}
// Check validity of some basic values
const auto checkResultError = passkeyUtils()->checkLimits(publicKeyOptions);
if (checkResultError > 0) {
return checkResultError;
}
// Get effective domain
QString effectiveDomain;
const auto effectiveDomainResponse = passkeyUtils()->getEffectiveDomain(origin, &effectiveDomain);
if (effectiveDomainResponse > 0) {
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;
}
// Check PublicKeyCredentialTypes
const auto pubKeyCredParams = passkeyUtils()->parseCredentialTypes(publicKeyOptions["pubKeyCredParams"].toArray());
if (pubKeyCredParams.isEmpty() && !publicKeyOptions["pubKeyCredParams"].toArray().isEmpty()) {
return ERROR_PASSKEYS_NO_SUPPORTED_ALGORITHMS;
}
// Check Attestation
const auto attestation = passkeyUtils()->parseAttestation(publicKeyOptions["attestation"].toString());
// Check validity of AuthenticatorSelection
auto authenticatorSelection = publicKeyOptions["authenticatorSelection"].toObject();
const bool isAuthenticatorSelectionValid = passkeyUtils()->isAuthenticatorSelectionValid(authenticatorSelection);
if (!isAuthenticatorSelectionValid) {
return ERROR_PASSKEYS_WAIT_FOR_LIFETIMER;
}
// Add default values for compatibility
if (authenticatorSelection.isEmpty()) {
authenticatorSelection = QJsonObject({{"userVerification", BrowserPasskeys::REQUIREMENT_PREFERRED}});
} else if (authenticatorSelection["userVerification"].toString().isEmpty()) {
authenticatorSelection["userVerification"] = BrowserPasskeys::REQUIREMENT_PREFERRED;
}
auto authenticatorAttachment = authenticatorSelection["authenticatorAttachment"].toString();
if (authenticatorAttachment.isEmpty()) {
authenticatorAttachment = BrowserPasskeys::ATTACHMENT_PLATFORM;
}
// Unknown values are ignored, but a warning will be still shown just in case
const auto userVerification = authenticatorSelection["userVerification"].toString();
if (!passkeyUtils()->isUserVerificationValid(userVerification)) {
qWarning() << browserMessageBuilder()->getErrorMessage(ERROR_PASSKEYS_INVALID_USER_VERIFICATION);
}
// Parse requireResidentKey and userVerification
const auto isResidentKeyRequired = passkeyUtils()->isResidentKeyRequired(authenticatorSelection);
const auto isUserVerificationRequired = passkeyUtils()->isUserVerificationRequired(authenticatorSelection);
// Extensions
auto extensionObject = publicKeyOptions["extensions"].toObject();
const auto extensionData = passkeyUtils()->buildExtensionData(extensionObject);
const auto extensions = browserMessageBuilder()->getBase64FromArray(extensionData);
// Construct the final object
QJsonObject credentialCreationOptions;
credentialCreationOptions["attestation"] = attestation; // Set this, even if only "none" is supported
credentialCreationOptions["authenticatorAttachment"] = authenticatorAttachment;
credentialCreationOptions["clientDataJSON"] = passkeyUtils()->buildClientDataJson(publicKeyOptions, origin, false);
credentialCreationOptions["credTypesAndPubKeyAlgs"] = pubKeyCredParams;
credentialCreationOptions["excludeCredentials"] = publicKeyOptions["excludeCredentials"];
credentialCreationOptions["extensions"] = extensions;
credentialCreationOptions["residentKey"] = isResidentKeyRequired;
credentialCreationOptions["rp"] = QJsonObject({{"id", rpId}, {"name", rpName}});
credentialCreationOptions["user"] = publicKeyOptions["user"];
credentialCreationOptions["userPresence"] = !isUserVerificationRequired;
credentialCreationOptions["userVerification"] = isUserVerificationRequired;
*result = credentialCreationOptions;
return 0;
}
// Use an existing credential
// https://www.w3.org/TR/2019/REC-webauthn-1-20190304/#getAssertion
int BrowserPasskeysClient::getAssertionOptions(const QJsonObject& publicKeyOptions,
const QString& origin,
QJsonObject* result) const
{
if (!result || publicKeyOptions.isEmpty()) {
return ERROR_PASSKEYS_EMPTY_PUBLIC_KEY;
}
// Get effective domain
QString effectiveDomain;
const auto effectiveDomainResponse = passkeyUtils()->getEffectiveDomain(origin, &effectiveDomain);
if (effectiveDomainResponse > 0) {
return effectiveDomainResponse;
}
// Validate RP ID
QString rpId;
const auto rpIdResponse = passkeyUtils()->validateRpId(publicKeyOptions["rpId"], effectiveDomain, &rpId);
if (rpIdResponse > 0) {
return rpIdResponse;
}
// Extensions
auto extensionObject = publicKeyOptions["extensions"].toObject();
const auto extensionData = passkeyUtils()->buildExtensionData(extensionObject);
const auto extensions = browserMessageBuilder()->getBase64FromArray(extensionData);
// clientDataJson
const auto clientDataJson = passkeyUtils()->buildClientDataJson(publicKeyOptions, origin, true);
// Unknown values are ignored, but a warning will be still shown just in case
const auto userVerification = publicKeyOptions["userVerification"].toString();
if (!passkeyUtils()->isUserVerificationValid(userVerification)) {
qWarning() << browserMessageBuilder()->getErrorMessage(ERROR_PASSKEYS_INVALID_USER_VERIFICATION);
}
const auto isUserVerificationRequired = passkeyUtils()->isUserVerificationRequired(publicKeyOptions);
QJsonObject assertionOptions;
assertionOptions["allowCredentials"] = publicKeyOptions["allowCredentials"];
assertionOptions["clientDataJson"] = clientDataJson;
assertionOptions["extensions"] = extensions;
assertionOptions["rpId"] = rpId;
assertionOptions["userPresence"] = true;
assertionOptions["userVerification"] = isUserVerificationRequired;
*result = assertionOptions;
return 0;
}

View File

@ -0,0 +1,49 @@
/*
* 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/>.
*/
#ifndef BROWSERPASSKEYSCLIENT_H
#define BROWSERPASSKEYSCLIENT_H
#include <QJsonArray>
#include <QJsonObject>
#include <QObject>
class BrowserPasskeysClient : public QObject
{
Q_OBJECT
public:
explicit BrowserPasskeysClient() = default;
~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;
private:
Q_DISABLE_COPY(BrowserPasskeysClient);
friend class TestPasskeys;
};
static inline BrowserPasskeysClient* browserPasskeysClient()
{
return BrowserPasskeysClient::instance();
}
#endif // BROWSERPASSKEYSCLIENT_H

View File

@ -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));
});
}

View File

@ -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
*
@ -88,9 +88,10 @@ public:
QSharedPointer<Database> selectedDatabase();
QList<QSharedPointer<Database>> getOpenDatabases();
#ifdef WITH_XC_BROWSER_PASSKEYS
QJsonObject
showPasskeysRegisterPrompt(const QJsonObject& publicKey, const QString& origin, const StringPairList& keyList);
QJsonObject showPasskeysAuthenticationPrompt(const QJsonObject& publicKey,
QJsonObject showPasskeysRegisterPrompt(const QJsonObject& publicKeyOptions,
const QString& origin,
const StringPairList& keyList);
QJsonObject showPasskeysAuthenticationPrompt(const QJsonObject& publicKeyOptions,
const QString& origin,
const StringPairList& keyList);
void addPasskeyToGroup(Group* group,
@ -177,18 +178,15 @@ private:
Access checkAccess(const Entry* entry, const QString& siteHost, const QString& formHost, const QString& realm);
Group* getDefaultEntryGroup(const QSharedPointer<Database>& selectedDb = {});
int sortPriority(const QStringList& urls, const QString& siteUrl, const QString& formUrl);
bool schemeFound(const QString& url);
bool removeFirstDomain(QString& hostname);
bool
shouldIncludeEntry(Entry* entry, const QString& url, const QString& submitUrl, const bool omitWwwSubdomain = false);
#ifdef WITH_XC_BROWSER_PASSKEYS
QList<Entry*> getPasskeyEntries(const QString& rpId, const StringPairList& keyList);
QList<Entry*>
getPasskeyAllowedEntries(const QJsonObject& publicKey, const QString& rpId, const StringPairList& keyList);
QJsonObject
getPublicKeyCredentialFromEntry(const Entry* entry, const QJsonObject& publicKey, const QString& origin);
getPasskeyAllowedEntries(const QJsonObject& assertionOptions, const QString& rpId, const StringPairList& keyList);
bool isPasskeyCredentialExcluded(const QJsonArray& excludeCredentials,
const QString& origin,
const QString& rpId,
const StringPairList& keyList);
QJsonObject getPasskeyError(int errorCode) const;
#endif

View File

@ -1,7 +1,7 @@
/*
* Copyright (C) 2013 Francois Ferrand
* Copyright (C) 2024 KeePassXC Team <team@keepassxc.org>
* Copyright (C) 2017 Sami Vänttinen <sami.vanttinen@protonmail.com>
* Copyright (C) 2021 KeePassXC Team <team@keepassxc.org>
* Copyright (C) 2013 Francois Ferrand
*
* 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
@ -145,6 +145,16 @@ void BrowserSettings::setNoMigrationPrompt(bool prompt)
config()->set(Config::Browser_NoMigrationPrompt, prompt);
}
bool BrowserSettings::allowLocalhostWithPasskeys()
{
return config()->get(Config::Browser_AllowLocalhostWithPasskeys).toBool();
}
void BrowserSettings::setAllowLocalhostWithPasskeys(bool enabled)
{
config()->set(Config::Browser_AllowLocalhostWithPasskeys, enabled);
}
bool BrowserSettings::useCustomProxy()
{
return config()->get(Config::Browser_UseCustomProxy).toBool();

View File

@ -1,7 +1,7 @@
/*
* Copyright (C) 2013 Francois Ferrand
* Copyright (C) 2024 KeePassXC Team <team@keepassxc.org>
* Copyright (C) 2017 Sami Vänttinen <sami.vanttinen@protonmail.com>
* Copyright (C) 2021 KeePassXC Team <team@keepassxc.org>
* Copyright (C) 2013 Francois Ferrand
*
* 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
@ -51,6 +51,8 @@ public:
void setSupportKphFields(bool supportKphFields);
bool noMigrationPrompt();
void setNoMigrationPrompt(bool prompt);
bool allowLocalhostWithPasskeys();
void setAllowLocalhostWithPasskeys(bool enabled);
bool useCustomProxy();
void setUseCustomProxy(bool enabled);

View File

@ -123,6 +123,7 @@ void BrowserSettingsWidget::loadSettings()
m_ui->httpAuthPermission->setChecked(settings->httpAuthPermission());
m_ui->searchInAllDatabases->setChecked(settings->searchInAllDatabases());
m_ui->supportKphFields->setChecked(settings->supportKphFields());
m_ui->allowLocalhostWithPasskeys->setChecked(settings->allowLocalhostWithPasskeys());
m_ui->noMigrationPrompt->setChecked(settings->noMigrationPrompt());
m_ui->useCustomProxy->setChecked(settings->useCustomProxy());
m_ui->customProxyLocation->setText(settings->replaceHomePath(settings->customProxyLocation()));
@ -253,6 +254,7 @@ void BrowserSettingsWidget::saveSettings()
settings->setHttpAuthPermission(m_ui->httpAuthPermission->isChecked());
settings->setSearchInAllDatabases(m_ui->searchInAllDatabases->isChecked());
settings->setSupportKphFields(m_ui->supportKphFields->isChecked());
settings->setAllowLocalhostWithPasskeys(m_ui->allowLocalhostWithPasskeys->isChecked());
settings->setNoMigrationPrompt(m_ui->noMigrationPrompt->isChecked());
#ifdef QT_DEBUG

View File

@ -310,6 +310,16 @@
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="allowLocalhostWithPasskeys">
<property name="toolTip">
<string>Allows using insecure http://localhost with Passkeys for testing purposes.</string>
</property>
<property name="text">
<string>Allow using localhost with Passkeys</string>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="noMigrationPrompt">
<property name="toolTip">

View File

@ -1,4 +1,4 @@
# Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
# 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
@ -35,7 +35,9 @@ if(WITH_XC_BROWSER)
list(APPEND keepassxcbrowser_SOURCES
BrowserCbor.cpp
BrowserPasskeys.cpp
BrowserPasskeysConfirmationDialog.cpp)
BrowserPasskeysClient.cpp
BrowserPasskeysConfirmationDialog.cpp
PasskeyUtils.cpp)
endif()
add_library(keepassxcbrowser STATIC ${keepassxcbrowser_SOURCES})

View File

@ -0,0 +1,352 @@
/*
* 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();
const auto hasSupportedTransport =
transports.isEmpty() || transports.contains(BrowserPasskeys::AUTHENTICATOR_TRANSPORT);
if (cred["type"].toString() == BrowserPasskeys::PUBLIC_KEY && hasSupportedTransport && !id.isEmpty()) {
allowedCredentials << id;
}
}
return allowedCredentials;
}

View File

@ -0,0 +1,74 @@
/*
* 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/>.
*/
#ifndef PASSKEYUTILS_H
#define PASSKEYUTILS_H
#include <QJsonArray>
#include <QJsonObject>
#include <QObject>
#include <QStringList>
#include "BrowserCbor.h"
#define DEFAULT_TIMEOUT 300000
#define DEFAULT_DISCOURAGED_TIMEOUT 120000
#define PASSKEYS_SUCCESS 0
class PasskeyUtils : public QObject
{
Q_OBJECT
public:
explicit PasskeyUtils() = default;
~PasskeyUtils() = default;
static PasskeyUtils* instance();
int checkLimits(const QJsonObject& pkOptions) const;
bool checkCredentialCreationOptions(const QJsonObject& credentialCreationOptions) const;
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;
QString parseAttestation(const QString& attestation) const;
QJsonArray parseCredentialTypes(const QJsonArray& credentialTypes) const;
bool isAuthenticatorSelectionValid(const QJsonObject& authenticatorSelection) const;
bool isUserVerificationValid(const QString& userVerification) const;
bool isResidentKeyRequired(const QJsonObject& authenticatorSelection) const;
bool isUserVerificationRequired(const QJsonObject& authenticatorSelection) const;
bool isOriginAllowedWithLocalhost(bool allowLocalhostWithPasskeys, const QString& origin) const;
QByteArray buildExtensionData(QJsonObject& extensionObject) const;
QJsonObject buildClientDataJson(const QJsonObject& publicKey, const QString& origin, bool get) const;
QStringList getAllowedCredentialsFromAssertionOptions(const QJsonObject& assertionOptions) const;
private:
Q_DISABLE_COPY(PasskeyUtils);
bool isRegistrableDomainSuffix(const QString& hostSuffixString, const QString& originalHost) const;
bool isDomain(const QString& hostName) const;
friend class TestPasskeys;
private:
BrowserCbor m_browserCbor;
};
static inline PasskeyUtils* passkeyUtils()
{
return PasskeyUtils::instance();
}
#endif // PASSKEYUTILS_H

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2020 KeePassXC Team <team@keepassxc.org>
* Copyright (C) 2024 KeePassXC Team <team@keepassxc.org>
* Copyright (C) 2011 Felix Geyer <debfx@fobos.de>
*
* This program is free software: you can redistribute it and/or modify
@ -167,6 +167,7 @@ static const QHash<Config::ConfigKey, ConfigDirective> configStrings = {
{Config::Browser_UseCustomBrowser, {QS("Browser/UseCustomBrowser"), Local, false}},
{Config::Browser_CustomBrowserType, {QS("Browser/CustomBrowserType"), Local, -1}},
{Config::Browser_CustomBrowserLocation, {QS("Browser/CustomBrowserLocation"), Local, {}}},
{Config::Browser_AllowLocalhostWithPasskeys, {QS("Browser/Browser_AllowLocalhostWithPasskeys"), Roaming, false}},
#ifdef QT_DEBUG
{Config::Browser_CustomExtensionId, {QS("Browser/CustomExtensionId"), Local, {}}},
#endif

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2020 KeePassXC Team <team@keepassxc.org>
* Copyright (C) 2024 KeePassXC Team <team@keepassxc.org>
* Copyright (C) 2011 Felix Geyer <debfx@fobos.de>
*
* This program is free software: you can redistribute it and/or modify
@ -147,6 +147,7 @@ public:
Browser_UseCustomBrowser,
Browser_CustomBrowserType,
Browser_CustomBrowserLocation,
Browser_AllowLocalhostWithPasskeys,
#ifdef QT_DEBUG
Browser_CustomExtensionId,
#endif

View File

@ -230,6 +230,13 @@ namespace Tools
return regexp.exactMatch(base64);
}
bool isAsciiString(const QString& str)
{
constexpr auto pattern = R"(^[\x00-\x7F]+$)";
QRegularExpression regexp(pattern, QRegularExpression::CaseInsensitiveOption);
return regexp.match(str).hasMatch();
}
void sleep(int ms)
{
Q_ASSERT(ms >= 0);

View File

@ -1,6 +1,6 @@
/*
* Copyright (C) 2024 KeePassXC Team <team@keepassxc.org>
* Copyright (C) 2012 Felix Geyer <debfx@fobos.de>
* Copyright (C) 2023 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
@ -37,6 +37,7 @@ namespace Tools
bool readAllFromDevice(QIODevice* device, QByteArray& data);
bool isHex(const QByteArray& ba);
bool isBase64(const QByteArray& ba);
bool isAsciiString(const QString& str);
void sleep(int ms);
void wait(int ms);
QString uuidToHex(const QUuid& uuid);

View File

@ -102,7 +102,7 @@ QString UrlTools::getTopLevelDomainFromUrl(const QString& url) const
cookie.setDomain(host);
// Check if dummy cookie's domain/TLD matches with public suffix list
if (!QNetworkCookieJar{}.setCookiesFromUrl(QList{cookie}, url)) {
if (!QNetworkCookieJar{}.setCookiesFromUrl(QList{cookie}, QUrl::fromUserInput(url))) {
return host;
}
}
@ -112,7 +112,9 @@ QString UrlTools::getTopLevelDomainFromUrl(const QString& url) const
bool UrlTools::isIpAddress(const QString& host) const
{
QHostAddress address(host);
// Handle IPv6 host with brackets, e.g [::1]
const auto hostAddress = host.startsWith('[') && host.endsWith(']') ? host.mid(1, host.length() - 2) : host;
QHostAddress address(hostAddress);
return address.protocol() == QAbstractSocket::IPv4Protocol || address.protocol() == QAbstractSocket::IPv6Protocol;
}
#endif
@ -171,3 +173,9 @@ bool UrlTools::isUrlValid(const QString& urlField) const
return true;
}
bool UrlTools::domainHasIllegalCharacters(const QString& domain) const
{
QRegularExpression re(R"([\s\^#|/:<>\?@\[\]\\])");
return re.match(domain).hasMatch();
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
* 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
@ -40,6 +40,7 @@ public:
#endif
bool isUrlIdentical(const QString& first, const QString& second) const;
bool isUrlValid(const QString& urlField) const;
bool domainHasIllegalCharacters(const QString& domain) const;
private:
QUrl convertVariantToUrl(const QVariant& var) const;

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
* 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
@ -91,7 +91,7 @@ void PasskeyExporter::exportSelectedEntry(const Entry* entry, const QString& fol
passkeyObject["relyingParty"] = entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_RELYING_PARTY);
passkeyObject["url"] = entry->url();
passkeyObject["username"] = entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_USERNAME);
passkeyObject["credentialId"] = entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_GENERATED_USER_ID);
passkeyObject["credentialId"] = entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_CREDENTIAL_ID);
passkeyObject["userHandle"] = entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_USER_HANDLE);
passkeyObject["privateKey"] = entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_PRIVATE_KEY_PEM);

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
* 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
@ -18,7 +18,9 @@
#include "TestPasskeys.h"
#include "browser/BrowserCbor.h"
#include "browser/BrowserMessageBuilder.h"
#include "browser/BrowserPasskeysClient.h"
#include "browser/BrowserService.h"
#include "browser/PasskeyUtils.h"
#include "core/Database.h"
#include "core/Entry.h"
#include "core/Group.h"
@ -98,6 +100,18 @@ const QString PublicKeyCredentialRequestOptions = R"(
"userVerification": "required"
}
)";
const QJsonArray validPubKeyCredParams = {
QJsonObject({
{"type", "public-key"},
{"alg", -7}
}),
QJsonObject({
{"type", "public-key"},
{"alg", -257}
}),
};
// clang-format on
void TestPasskeys::initTestCase()
@ -252,14 +266,21 @@ void TestPasskeys::testCreatingAttestationObjectWithEC()
const auto predefinedSecond = QString("4u5_6Q8O6R0Hg0oDCdtCJLEL0yX_GDLhU5m3HUIE54M");
const auto publicKeyCredentialOptions = browserMessageBuilder()->getJsonObject(PublicKeyCredentialOptions.toUtf8());
QJsonObject credentialCreationOptions;
browserPasskeysClient()->getCredentialCreationOptions(
publicKeyCredentialOptions, QString("https://webauthn.io"), &credentialCreationOptions);
auto rpIdHash = browserMessageBuilder()->getSha256HashAsBase64(QString("webauthn.io"));
QCOMPARE(rpIdHash, QString("dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvA"));
TestingVariables testingVariables = {id, predefinedFirst, predefinedSecond};
auto result = browserPasskeys()->buildAttestationObject(publicKeyCredentialOptions, "", id, testingVariables);
const auto alg = browserPasskeys()->getAlgorithmFromPublicKey(credentialCreationOptions);
const auto credentialPrivateKey =
browserPasskeys()->buildCredentialPrivateKey(alg, predefinedFirst, predefinedSecond);
auto result = browserPasskeys()->buildAttestationObject(
credentialCreationOptions, "", id, credentialPrivateKey.cborEncodedPublicKey, testingVariables);
QCOMPARE(
QString(result.cborEncoded),
result,
QString("\xA3"
"cfmtdnonegattStmt\xA0hauthDataX\xA4t\xA6\xEA\x92\x13\xC9\x9C/t\xB2$\x92\xB3 \xCF@&*\x94\xC1\xA9P\xA0"
"9\x7F)%\x0B`\x84\x1E\xF0"
@ -272,7 +293,7 @@ void TestPasskeys::testCreatingAttestationObjectWithEC()
// Double check that the result can be decoded
BrowserCbor browserCbor;
auto attestationJsonObject = browserCbor.getJsonFromCborData(result.cborEncoded);
auto attestationJsonObject = browserCbor.getJsonFromCborData(result);
// Parse authData
auto authDataJsonObject = attestationJsonObject["authData"].toString();
@ -311,18 +332,25 @@ void TestPasskeys::testCreatingAttestationObjectWithRSA()
QJsonArray pubKeyCredParams;
pubKeyCredParams.append(QJsonObject({{"type", "public-key"}, {"alg", -257}}));
auto publicKeyCredentialOptions = browserMessageBuilder()->getJsonObject(PublicKeyCredentialOptions.toUtf8());
publicKeyCredentialOptions["pubKeyCredParams"] = pubKeyCredParams;
const auto publicKeyCredentialOptions = browserMessageBuilder()->getJsonObject(PublicKeyCredentialOptions.toUtf8());
QJsonObject credentialCreationOptions;
browserPasskeysClient()->getCredentialCreationOptions(
publicKeyCredentialOptions, QString("https://webauthn.io"), &credentialCreationOptions);
credentialCreationOptions["credTypesAndPubKeyAlgs"] = pubKeyCredParams;
auto rpIdHash = browserMessageBuilder()->getSha256HashAsBase64(QString("webauthn.io"));
QCOMPARE(rpIdHash, QString("dKbqkhPJnC90siSSsyDPQCYqlMGpUKA5fyklC2CEHvA"));
TestingVariables testingVariables = {id, predefinedModulus, predefinedExponent};
auto result = browserPasskeys()->buildAttestationObject(publicKeyCredentialOptions, "", id, testingVariables);
const auto alg = browserPasskeys()->getAlgorithmFromPublicKey(credentialCreationOptions);
auto credentialPrivateKey =
browserPasskeys()->buildCredentialPrivateKey(alg, predefinedModulus, predefinedExponent);
auto result = browserPasskeys()->buildAttestationObject(
credentialCreationOptions, "", id, credentialPrivateKey.cborEncodedPublicKey, testingVariables);
// Double check that the result can be decoded
BrowserCbor browserCbor;
auto attestationJsonObject = browserCbor.getJsonFromCborData(result.cborEncoded);
auto attestationJsonObject = browserCbor.getJsonFromCborData(result);
// Parse authData
auto authDataJsonObject = attestationJsonObject["authData"].toString();
@ -356,9 +384,13 @@ void TestPasskeys::testRegister()
const auto testDataResponse = testDataPublicKey["response"];
const auto publicKeyCredentialOptions = browserMessageBuilder()->getJsonObject(PublicKeyCredentialOptions.toUtf8());
QJsonObject credentialCreationOptions;
const auto creationResult = browserPasskeysClient()->getCredentialCreationOptions(
publicKeyCredentialOptions, origin, &credentialCreationOptions);
QVERIFY(creationResult == 0);
TestingVariables testingVariables = {predefinedId, predefinedX, predefinedY};
auto result =
browserPasskeys()->buildRegisterPublicKeyCredential(publicKeyCredentialOptions, origin, testingVariables);
auto result = browserPasskeys()->buildRegisterPublicKeyCredential(credentialCreationOptions, testingVariables);
auto publicKeyCredential = result.response;
QCOMPARE(publicKeyCredential["type"], QString("public-key"));
QCOMPARE(publicKeyCredential["authenticatorAttachment"], QString("platform"));
@ -390,8 +422,12 @@ void TestPasskeys::testGet()
const auto publicKeyCredentialRequestOptions =
browserMessageBuilder()->getJsonObject(PublicKeyCredentialRequestOptions.toUtf8());
auto publicKeyCredential = browserPasskeys()->buildGetPublicKeyCredential(
publicKeyCredentialRequestOptions, origin, id, {}, privateKeyPem);
QJsonObject assertionOptions;
const auto assertionResult =
browserPasskeysClient()->getAssertionOptions(publicKeyCredentialRequestOptions, origin, &assertionOptions);
QVERIFY(assertionResult == 0);
auto publicKeyCredential = browserPasskeys()->buildGetPublicKeyCredential(assertionOptions, id, {}, privateKeyPem);
QVERIFY(!publicKeyCredential.isEmpty());
QCOMPARE(publicKeyCredential["id"].toString(), id);
@ -414,7 +450,7 @@ void TestPasskeys::testGet()
void TestPasskeys::testExtensions()
{
auto extensions = QJsonObject({{"credProps", true}, {"uvm", true}});
auto result = browserPasskeys()->buildExtensionData(extensions);
auto result = passkeyUtils()->buildExtensionData(extensions);
BrowserCbor cbor;
auto extensionJson = cbor.getJsonFromCborData(result);
@ -425,8 +461,8 @@ void TestPasskeys::testExtensions()
auto partial = QJsonObject({{"props", true}, {"uvm", true}});
auto faulty = QJsonObject({{"uvx", true}});
auto partialData = browserPasskeys()->buildExtensionData(partial);
auto faultyData = browserPasskeys()->buildExtensionData(faulty);
auto partialData = passkeyUtils()->buildExtensionData(partial);
auto faultyData = passkeyUtils()->buildExtensionData(faulty);
auto partialJson = cbor.getJsonFromCborData(partialData);
QCOMPARE(partialJson["uvm"].toArray().size(), 1);
@ -496,3 +532,164 @@ void TestPasskeys::testEntry()
QVERIFY(entry->hasPasskey());
}
void TestPasskeys::testIsDomain()
{
QVERIFY(passkeyUtils()->isDomain("test.example.com"));
QVERIFY(passkeyUtils()->isDomain("example.com"));
QVERIFY(!passkeyUtils()->isDomain("exa[mple.org"));
QVERIFY(!passkeyUtils()->isDomain("example.com."));
QVERIFY(!passkeyUtils()->isDomain("127.0.0.1"));
QVERIFY(!passkeyUtils()->isDomain("127.0.0.1."));
}
// List from https://html.spec.whatwg.org/multipage/browsers.html#is-a-registrable-domain-suffix-of-or-is-equal-to
void TestPasskeys::testRegistrableDomainSuffix()
{
QVERIFY(passkeyUtils()->isRegistrableDomainSuffix(QString("example.com"), QString("example.com")));
QVERIFY(!passkeyUtils()->isRegistrableDomainSuffix(QString("example.com"), QString("example.com.")));
QVERIFY(!passkeyUtils()->isRegistrableDomainSuffix(QString("example.com."), QString("example.com")));
QVERIFY(passkeyUtils()->isRegistrableDomainSuffix(QString("example.com"), QString("www.example.com")));
QVERIFY(!passkeyUtils()->isRegistrableDomainSuffix(QString("com"), QString("example.com")));
QVERIFY(passkeyUtils()->isRegistrableDomainSuffix(QString("example"), QString("example")));
QVERIFY(
!passkeyUtils()->isRegistrableDomainSuffix(QString("s3.amazonaws.com"), QString("example.s3.amazonaws.com")));
QVERIFY(!passkeyUtils()->isRegistrableDomainSuffix(QString("example.compute.amazonaws.com"),
QString("www.example.compute.amazonaws.com")));
QVERIFY(!passkeyUtils()->isRegistrableDomainSuffix(QString("amazonaws.com"),
QString("www.example.compute.amazonaws.com")));
QVERIFY(passkeyUtils()->isRegistrableDomainSuffix(QString("amazonaws.com"), QString("test.amazonaws.com")));
}
void TestPasskeys::testRpIdValidation()
{
QString result;
auto allowedIdentical = passkeyUtils()->validateRpId(QString("example.com"), QString("example.com"), &result);
QCOMPARE(result, QString("example.com"));
QVERIFY(allowedIdentical == 0);
result.clear();
auto allowedSubdomain = passkeyUtils()->validateRpId(QString("example.com"), QString("www.example.com"), &result);
QCOMPARE(result, QString("example.com"));
QVERIFY(allowedSubdomain == 0);
result.clear();
auto emptyRpId = passkeyUtils()->validateRpId({}, QString("example.com"), &result);
QCOMPARE(result, QString(""));
QVERIFY(emptyRpId == ERROR_PASSKEYS_DOMAIN_RPID_MISMATCH);
result.clear();
auto ipRpId = passkeyUtils()->validateRpId(QString("127.0.0.1"), QString("example.com"), &result);
QCOMPARE(result, QString(""));
QVERIFY(ipRpId == ERROR_PASSKEYS_DOMAIN_RPID_MISMATCH);
result.clear();
auto emptyOrigin = passkeyUtils()->validateRpId(QString("example.com"), QString(""), &result);
QVERIFY(result.isEmpty());
QCOMPARE(emptyOrigin, ERROR_PASSKEYS_ORIGIN_NOT_ALLOWED);
result.clear();
auto ipOrigin = passkeyUtils()->validateRpId(QString("example.com"), QString("127.0.0.1"), &result);
QVERIFY(result.isEmpty());
QCOMPARE(ipOrigin, ERROR_PASSKEYS_DOMAIN_RPID_MISMATCH);
result.clear();
auto invalidRpId = passkeyUtils()->validateRpId(QString(".com"), QString("example.com"), &result);
QVERIFY(result.isEmpty());
QCOMPARE(invalidRpId, ERROR_PASSKEYS_DOMAIN_RPID_MISMATCH);
result.clear();
auto malformedOrigin = passkeyUtils()->validateRpId(QString("example.com."), QString("example.com."), &result);
QVERIFY(result.isEmpty());
QCOMPARE(malformedOrigin, ERROR_PASSKEYS_DOMAIN_RPID_MISMATCH);
result.clear();
auto malformed = passkeyUtils()->validateRpId(QString("...com."), QString("example...com"), &result);
QVERIFY(result.isEmpty());
QCOMPARE(malformed, ERROR_PASSKEYS_DOMAIN_RPID_MISMATCH);
result.clear();
auto differentDomain = passkeyUtils()->validateRpId(QString("another.com"), QString("example.com"), &result);
QVERIFY(result.isEmpty());
QCOMPARE(differentDomain, ERROR_PASSKEYS_DOMAIN_RPID_MISMATCH);
}
void TestPasskeys::testParseAttestation()
{
QVERIFY(passkeyUtils()->parseAttestation(QString("")) == QString("none"));
QVERIFY(passkeyUtils()->parseAttestation(QString("direct")) == QString("direct"));
QVERIFY(passkeyUtils()->parseAttestation(QString("none")) == QString("none"));
QVERIFY(passkeyUtils()->parseAttestation(QString("indirect")) == QString("none"));
QVERIFY(passkeyUtils()->parseAttestation(QString("invalidvalue")) == QString("none"));
}
void TestPasskeys::testParseCredentialTypes()
{
const QJsonArray invalidPubKeyCredParams = {
QJsonObject({{"type", "private-key"}, {"alg", -7}}),
QJsonObject({{"type", "private-key"}, {"alg", -257}}),
};
const QJsonArray partiallyInvalidPubKeyCredParams = {
QJsonObject({{"type", "private-key"}, {"alg", -7}}),
QJsonObject({{"type", "public-key"}, {"alg", -257}}),
};
auto validResponse = passkeyUtils()->parseCredentialTypes(validPubKeyCredParams);
QVERIFY(validResponse == validPubKeyCredParams);
auto invalidResponse = passkeyUtils()->parseCredentialTypes(invalidPubKeyCredParams);
QVERIFY(invalidResponse.isEmpty());
auto partiallyInvalidResponse = passkeyUtils()->parseCredentialTypes(partiallyInvalidPubKeyCredParams);
QVERIFY(partiallyInvalidResponse != validPubKeyCredParams);
QVERIFY(partiallyInvalidResponse.size() == 1);
QVERIFY(partiallyInvalidResponse.first()["type"].toString() == QString("public-key"));
QVERIFY(partiallyInvalidResponse.first()["alg"].toInt() == -257);
auto emptyResponse = passkeyUtils()->parseCredentialTypes({});
QVERIFY(emptyResponse == validPubKeyCredParams);
const auto publicKeyOptions = browserMessageBuilder()->getJsonObject(PublicKeyCredentialOptions.toUtf8());
auto responseFromPublicKey = passkeyUtils()->parseCredentialTypes(publicKeyOptions["pubKeyCredParams"].toArray());
QVERIFY(responseFromPublicKey == validPubKeyCredParams);
}
void TestPasskeys::testIsAuthenticatorSelectionValid()
{
QVERIFY(passkeyUtils()->isAuthenticatorSelectionValid({}));
QVERIFY(passkeyUtils()->isAuthenticatorSelectionValid(QJsonObject({{"authenticatorAttachment", "platform"}})));
QVERIFY(
passkeyUtils()->isAuthenticatorSelectionValid(QJsonObject({{"authenticatorAttachment", "cross-platform"}})));
QVERIFY(!passkeyUtils()->isAuthenticatorSelectionValid(QJsonObject({{"authenticatorAttachment", "something"}})));
}
void TestPasskeys::testIsResidentKeyRequired()
{
QVERIFY(passkeyUtils()->isResidentKeyRequired(QJsonObject({{"residentKey", "required"}})));
QVERIFY(passkeyUtils()->isResidentKeyRequired(QJsonObject({{"residentKey", "preferred"}})));
QVERIFY(!passkeyUtils()->isResidentKeyRequired(QJsonObject({{"residentKey", "discouraged"}})));
QVERIFY(passkeyUtils()->isResidentKeyRequired(QJsonObject({{"requireResidentKey", true}})));
}
void TestPasskeys::testIsUserVerificationRequired()
{
QVERIFY(passkeyUtils()->isUserVerificationRequired(QJsonObject({{"userVerification", "required"}})));
QVERIFY(passkeyUtils()->isUserVerificationRequired(QJsonObject({{"userVerification", "preferred"}})));
QVERIFY(!passkeyUtils()->isUserVerificationRequired(QJsonObject({{"userVerification", "discouraged"}})));
}
void TestPasskeys::testAllowLocalhostWithPasskeys()
{
QVERIFY(passkeyUtils()->isOriginAllowedWithLocalhost(false, "https://example.com"));
QVERIFY(!passkeyUtils()->isOriginAllowedWithLocalhost(false, "http://example.com"));
QVERIFY(passkeyUtils()->isOriginAllowedWithLocalhost(true, "https://example.com"));
QVERIFY(!passkeyUtils()->isOriginAllowedWithLocalhost(true, "http://example.com"));
QVERIFY(!passkeyUtils()->isOriginAllowedWithLocalhost(false, "http://localhost"));
QVERIFY(passkeyUtils()->isOriginAllowedWithLocalhost(true, "http://localhost"));
QVERIFY(!passkeyUtils()->isOriginAllowedWithLocalhost(true, "http://localhosting"));
QVERIFY(passkeyUtils()->isOriginAllowedWithLocalhost(true, "http://test.localhost"));
QVERIFY(!passkeyUtils()->isOriginAllowedWithLocalhost(false, "http://test.localhost"));
QVERIFY(!passkeyUtils()->isOriginAllowedWithLocalhost(true, "http://localhost.example.com"));
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
* 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
@ -45,5 +45,14 @@ private slots:
void testSetFlags();
void testEntry();
void testIsDomain();
void testRegistrableDomainSuffix();
void testRpIdValidation();
void testParseAttestation();
void testParseCredentialTypes();
void testIsAuthenticatorSelectionValid();
void testIsResidentKeyRequired();
void testIsUserVerificationRequired();
void testAllowLocalhostWithPasskeys();
};
#endif // KEEPASSXC_TESTPASSKEYS_H

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
* 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
@ -68,6 +68,14 @@ void TestTools::testIsBase64()
QVERIFY(!Tools::isBase64(QByteArray("123")));
}
void TestTools::testIsAsciiString()
{
QVERIFY(Tools::isAsciiString("abcd9876DEFGhijkMNO"));
QVERIFY(Tools::isAsciiString("-!&5a?`~"));
QVERIFY(!Tools::isAsciiString("Štest"));
QVERIFY(!Tools::isAsciiString("Ãß"));
}
void TestTools::testEnvSubstitute()
{
QProcessEnvironment environment;

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
* 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
@ -27,6 +27,7 @@ private slots:
void testHumanReadableFileSize();
void testIsHex();
void testIsBase64();
void testIsAsciiString();
void testEnvSubstitute();
void testValidUuid();
void testBackupFilePatternSubstitution_data();

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
* 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
@ -35,6 +35,7 @@ void TestUrlTools::testTopLevelDomain()
QList<QPair<QString, QString>> tldUrls{
{QString("https://another.example.co.uk"), QString("co.uk")},
{QString("https://www.example.com"), QString("com")},
{QString("https://example.com"), QString("com")},
{QString("https://github.com"), QString("com")},
{QString("http://test.net"), QString("net")},
{QString("http://so.many.subdomains.co.jp"), QString("co.jp")},
@ -81,6 +82,9 @@ void TestUrlTools::testIsIpAddress()
auto host6 = "fe80::1ff:fe23:4567:890a";
auto host7 = "2001:20::1";
auto host8 = "2001:0db8:85y3:0000:0000:8a2e:0370:7334"; // Not valid
auto host9 = "[::]";
auto host10 = "::";
auto host11 = "[2001:20::1]";
QVERIFY(!urlTools()->isIpAddress(host1));
QVERIFY(urlTools()->isIpAddress(host2));
@ -90,6 +94,9 @@ void TestUrlTools::testIsIpAddress()
QVERIFY(urlTools()->isIpAddress(host6));
QVERIFY(urlTools()->isIpAddress(host7));
QVERIFY(!urlTools()->isIpAddress(host8));
QVERIFY(urlTools()->isIpAddress(host9));
QVERIFY(urlTools()->isIpAddress(host10));
QVERIFY(urlTools()->isIpAddress(host11));
}
void TestUrlTools::testIsUrlIdentical()
@ -117,6 +124,7 @@ void TestUrlTools::testIsUrlValid()
urls["//github.com"] = true;
urls["github.com/{}<>"] = false;
urls["http:/example.com"] = false;
urls["http:/example.com."] = false;
urls["cmd://C:/Toolchains/msys2/usr/bin/mintty \"ssh jon@192.168.0.1:22\""] = true;
urls["file:///Users/testUser/Code/test.html"] = true;
urls["{REF:A@I:46C9B1FFBD4ABC4BBB260C6190BAD20C} "] = true;
@ -127,3 +135,10 @@ void TestUrlTools::testIsUrlValid()
QCOMPARE(urlTools()->isUrlValid(i.key()), i.value());
}
}
void TestUrlTools::testDomainHasIllegalCharacters()
{
QVERIFY(!urlTools()->domainHasIllegalCharacters("example.com"));
QVERIFY(urlTools()->domainHasIllegalCharacters("domain has spaces.com"));
QVERIFY(urlTools()->domainHasIllegalCharacters("example#|.com"));
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
* 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
@ -34,6 +34,7 @@ private slots:
void testIsIpAddress();
void testIsUrlIdentical();
void testIsUrlValid();
void testDomainHasIllegalCharacters();
private:
QPointer<UrlTools> m_urlTools;