diff --git a/share/translations/keepassxc_en.ts b/share/translations/keepassxc_en.ts
index 884c34d9e..1c0bbcdbb 100644
--- a/share/translations/keepassxc_en.ts
+++ b/share/translations/keepassxc_en.ts
@@ -972,6 +972,10 @@ Do you want to overwrite the Passkey in %1 - %2?
Converting attributes to custom data…
+
+ Passkey
+
+
Abort
@@ -1252,6 +1256,14 @@ Would you like to migrate your existing settings now?
<b>Error:</b> The installed proxy executable is missing from the expected location: %1<br/>Please set a custom proxy location in the advanced settings or reinstall the application.
+
+ Allows using insecure http://localhost with Passkeys for testing purposes.
+
+
+
+ Allow using localhost with Passkeys
+
+
CloneDialog
@@ -8208,11 +8220,7 @@ Kernel: %3 %4
- Cannot remove password: The database does not have a password.
-
-
-
- Cannot remove file key: The database does not have a file key.
+ Passkeys
@@ -8229,11 +8237,75 @@ This options is deprecated, use --set-key-file instead.
- Passkeys
+ allow screenshots and app recording (Windows/macOS)
- allow screenshots and app recording (Windows/macOS)
+ Origin is empty or not allowed
+
+
+
+ Effective domain is not a valid domain
+
+
+
+ Origin and RP ID do not match
+
+
+
+ No supported algorithms were provided
+
+
+
+ Wait for timer to expire
+
+
+
+ Unknown Passkeys error
+
+
+
+ Challenge is shorter than required minimum length
+
+
+
+ user.id does not match the required length
+
+
+
+ Access to all entries is denied
+
+
+
+ Attestation not supported
+
+
+
+ Credential is excluded
+
+
+
+ Passkeys request canceled
+
+
+
+ Invalid user verification
+
+
+
+ Empty public key
+
+
+
+ Invalid URL provided
+
+
+
+ Cannot remove password: The database does not have a password.
+
+
+
+ Cannot remove file key: The database does not have a file key.
diff --git a/src/browser/BrowserAction.cpp b/src/browser/BrowserAction.cpp
index 747ec5d76..4a031a1c8 100644
--- a/src/browser/BrowserAction.cpp
+++ b/src/browser/BrowserAction.cpp
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2023 KeePassXC Team
+ * Copyright (C) 2024 KeePassXC Team
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -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"
@@ -507,7 +508,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);
}
@@ -540,8 +541,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);
diff --git a/src/browser/BrowserAction.h b/src/browser/BrowserAction.h
index babca712d..f737982f0 100644
--- a/src/browser/BrowserAction.h
+++ b/src/browser/BrowserAction.h
@@ -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();
diff --git a/src/browser/BrowserCbor.cpp b/src/browser/BrowserCbor.cpp
index bcc7043ce..e0f05d34e 100644
--- a/src/browser/BrowserCbor.cpp
+++ b/src/browser/BrowserCbor.cpp
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2023 KeePassXC Team
+ * Copyright (C) 2024 KeePassXC Team
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -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;
}
}
diff --git a/src/browser/BrowserCbor.h b/src/browser/BrowserCbor.h
index 9fcb68533..52baa4fc8 100644
--- a/src/browser/BrowserCbor.h
+++ b/src/browser/BrowserCbor.h
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2023 KeePassXC Team
+ * Copyright (C) 2024 KeePassXC Team
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -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
diff --git a/src/browser/BrowserMessageBuilder.cpp b/src/browser/BrowserMessageBuilder.cpp
index 41d3cfe8f..317c161bd 100644
--- a/src/browser/BrowserMessageBuilder.cpp
+++ b/src/browser/BrowserMessageBuilder.cpp
@@ -126,6 +126,36 @@ QString BrowserMessageBuilder::getErrorMessage(const int errorCode) const
return QObject::tr("Cannot create new group");
case ERROR_KEEPASS_NO_VALID_UUID_PROVIDED:
return QObject::tr("No valid UUID provided");
+ case ERROR_KEEPASS_ACCESS_TO_ALL_ENTRIES_DENIED:
+ return QObject::tr("Access to all entries is denied");
+ case ERROR_PASSKEYS_ATTESTATION_NOT_SUPPORTED:
+ return QObject::tr("Attestation not supported");
+ case ERROR_PASSKEYS_CREDENTIAL_IS_EXCLUDED:
+ return QObject::tr("Credential is excluded");
+ case ERROR_PASSKEYS_REQUEST_CANCELED:
+ return QObject::tr("Passkeys request canceled");
+ case ERROR_PASSKEYS_INVALID_USER_VERIFICATION:
+ return QObject::tr("Invalid user verification");
+ case ERROR_PASSKEYS_EMPTY_PUBLIC_KEY:
+ return QObject::tr("Empty public key");
+ case ERROR_PASSKEYS_INVALID_URL_PROVIDED:
+ return QObject::tr("Invalid URL provided");
+ 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");
}
diff --git a/src/browser/BrowserMessageBuilder.h b/src/browser/BrowserMessageBuilder.h
index b9e172380..5a2f96e16 100644
--- a/src/browser/BrowserMessageBuilder.h
+++ b/src/browser/BrowserMessageBuilder.h
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2023 KeePassXC Team
+ * Copyright (C) 2024 KeePassXC Team
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -54,7 +54,15 @@ namespace
ERROR_PASSKEYS_REQUEST_CANCELED = 22,
ERROR_PASSKEYS_INVALID_USER_VERIFICATION = 23,
ERROR_PASSKEYS_EMPTY_PUBLIC_KEY = 24,
- ERROR_PASSKEYS_INVALID_URL_PROVIDED = 25
+ ERROR_PASSKEYS_INVALID_URL_PROVIDED = 25,
+ 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,
};
}
diff --git a/src/browser/BrowserPasskeys.cpp b/src/browser/BrowserPasskeys.cpp
index db9d0651c..3fe8e006f 100644
--- a/src/browser/BrowserPasskeys.cpp
+++ b/src/browser/BrowserPasskeys.cpp
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2023 KeePassXC Team
+ * Copyright (C) 2024 KeePassXC Team
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -18,8 +18,8 @@
#include "BrowserPasskeys.h"
#include "BrowserMessageBuilder.h"
#include "BrowserService.h"
+#include "PasskeyUtils.h"
#include "crypto/Random.h"
-#include
#include
#include
#include
@@ -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(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) {
diff --git a/src/browser/BrowserPasskeys.h b/src/browser/BrowserPasskeys.h
index 206475bea..783d5ca68 100644
--- a/src/browser/BrowserPasskeys.h
+++ b/src/browser/BrowserPasskeys.h
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2023 KeePassXC Team
+ * Copyright (C) 2024 KeePassXC Team
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -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);
diff --git a/src/browser/BrowserPasskeysClient.cpp b/src/browser/BrowserPasskeysClient.cpp
new file mode 100644
index 000000000..15c5ffae2
--- /dev/null
+++ b/src/browser/BrowserPasskeysClient.cpp
@@ -0,0 +1,173 @@
+/*
+ * Copyright (C) 2024 KeePassXC Team
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * 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 .
+ */
+
+#include "BrowserPasskeysClient.h"
+#include "BrowserMessageBuilder.h"
+#include "BrowserPasskeys.h"
+#include "PasskeyUtils.h"
+
+#include
+
+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;
+}
diff --git a/src/browser/BrowserPasskeysClient.h b/src/browser/BrowserPasskeysClient.h
new file mode 100644
index 000000000..24040bd3e
--- /dev/null
+++ b/src/browser/BrowserPasskeysClient.h
@@ -0,0 +1,49 @@
+/*
+ * Copyright (C) 2024 KeePassXC Team
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * 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 .
+ */
+
+#ifndef BROWSERPASSKEYSCLIENT_H
+#define BROWSERPASSKEYSCLIENT_H
+
+#include
+#include
+#include
+
+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
diff --git a/src/browser/BrowserService.cpp b/src/browser/BrowserService.cpp
index 9d68048e9..5646bf3d0 100644
--- a/src/browser/BrowserService.cpp
+++ b/src/browser/BrowserService.cpp
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2023 KeePassXC Team
+ * Copyright (C) 2024 KeePassXC Team
* Copyright (C) 2017 Sami Vänttinen
* 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"
@@ -562,7 +564,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)
{
@@ -571,33 +573,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();
-
- // Only support these two for now
- if (attestation != BrowserPasskeys::PASSKEYS_ATTESTATION_NONE
- && attestation != BrowserPasskeys::PASSKEYS_ATTESTATION_DIRECT) {
- return getPasskeyError(ERROR_PASSKEYS_ATTESTATION_NOT_SUPPORTED);
+ QJsonObject credentialCreationOptions;
+ const auto pkOptionsResult =
+ browserPasskeysClient()->getCredentialCreationOptions(publicKeyOptions, origin, &credentialCreationOptions);
+ if (pkOptionsResult > 0 || credentialCreationOptions.isEmpty()) {
+ return getPasskeyError(pkOptionsResult);
}
- const auto authenticatorSelection = publicKey["authenticatorSelection"].toObject();
- const auto userVerification = authenticatorSelection["userVerification"].toString();
- if (!browserPasskeys()->isUserVerificationValid(userVerification)) {
- return getPasskeyError(ERROR_PASSKEYS_INVALID_USER_VERIFICATION);
- }
+ 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();
- 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;
@@ -605,7 +597,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(),
@@ -613,7 +614,7 @@ QJsonObject BrowserService::showPasskeysRegisterPrompt(const QJsonObject& public
rpName,
username,
publicKeyCredentials.credentialId,
- userHandle,
+ userId,
publicKeyCredentials.key);
} else {
addPasskeyToGroup(nullptr,
@@ -622,7 +623,7 @@ QJsonObject BrowserService::showPasskeysRegisterPrompt(const QJsonObject& public
rpName,
username,
publicKeyCredentials.credentialId,
- userHandle,
+ userId,
publicKeyCredentials.key);
}
@@ -635,7 +636,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)
{
@@ -644,24 +645,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;
@@ -670,7 +668,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();
@@ -742,10 +754,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();
}
@@ -1343,18 +1356,21 @@ QList BrowserService::getPasskeyEntries(const QString& rpId, const Strin
}
// Get all entries for the site that are allowed by the server
-QList BrowserService::getPasskeyAllowedEntries(const QJsonObject& publicKey,
+QList BrowserService::getPasskeyAllowedEntries(const QJsonObject& assertionOptions,
const QString& rpId,
const StringPairList& keyList)
{
QList 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;
@@ -1364,18 +1380,9 @@ QList 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;
@@ -1383,9 +1390,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));
});
}
diff --git a/src/browser/BrowserService.h b/src/browser/BrowserService.h
index da5133613..424992486 100644
--- a/src/browser/BrowserService.h
+++ b/src/browser/BrowserService.h
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2023 KeePassXC Team
+ * Copyright (C) 2024 KeePassXC Team
* Copyright (C) 2017 Sami Vänttinen
* Copyright (C) 2013 Francois Ferrand
*
@@ -87,9 +87,10 @@ public:
QSharedPointer selectedDatabase();
QList> 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,
@@ -173,18 +174,15 @@ private:
Access checkAccess(const Entry* entry, const QString& siteHost, const QString& formHost, const QString& realm);
Group* getDefaultEntryGroup(const QSharedPointer& 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 getPasskeyEntries(const QString& rpId, const StringPairList& keyList);
QList
- 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
diff --git a/src/browser/BrowserSettings.cpp b/src/browser/BrowserSettings.cpp
index c2980e74c..682a1a8ea 100644
--- a/src/browser/BrowserSettings.cpp
+++ b/src/browser/BrowserSettings.cpp
@@ -1,7 +1,7 @@
/*
- * Copyright (C) 2013 Francois Ferrand
+ * Copyright (C) 2024 KeePassXC Team
* Copyright (C) 2017 Sami Vänttinen
- * Copyright (C) 2021 KeePassXC Team
+ * 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();
diff --git a/src/browser/BrowserSettings.h b/src/browser/BrowserSettings.h
index 732064f20..a0aaed8d5 100644
--- a/src/browser/BrowserSettings.h
+++ b/src/browser/BrowserSettings.h
@@ -1,7 +1,7 @@
/*
- * Copyright (C) 2013 Francois Ferrand
+ * Copyright (C) 2024 KeePassXC Team
* Copyright (C) 2017 Sami Vänttinen
- * Copyright (C) 2021 KeePassXC Team
+ * 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);
diff --git a/src/browser/BrowserSettingsWidget.cpp b/src/browser/BrowserSettingsWidget.cpp
index dc07a61d2..36b7bdd4b 100644
--- a/src/browser/BrowserSettingsWidget.cpp
+++ b/src/browser/BrowserSettingsWidget.cpp
@@ -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()));
@@ -251,6 +252,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
diff --git a/src/browser/BrowserSettingsWidget.ui b/src/browser/BrowserSettingsWidget.ui
index 2e488c9d4..cb857e5c1 100644
--- a/src/browser/BrowserSettingsWidget.ui
+++ b/src/browser/BrowserSettingsWidget.ui
@@ -310,6 +310,16 @@
+ -
+
+
+ Allows using insecure http://localhost with Passkeys for testing purposes.
+
+
+ Allow using localhost with Passkeys
+
+
+
-
diff --git a/src/browser/CMakeLists.txt b/src/browser/CMakeLists.txt
index a614681f9..2c344d31b 100755
--- a/src/browser/CMakeLists.txt
+++ b/src/browser/CMakeLists.txt
@@ -1,4 +1,4 @@
-# Copyright (C) 2023 KeePassXC Team
+# Copyright (C) 2024 KeePassXC Team
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@@ -34,7 +34,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})
diff --git a/src/browser/PasskeyUtils.cpp b/src/browser/PasskeyUtils.cpp
new file mode 100644
index 000000000..1b4b59bf9
--- /dev/null
+++ b/src/browser/PasskeyUtils.cpp
@@ -0,0 +1,352 @@
+/*
+ * Copyright (C) 2024 KeePassXC Team
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * 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 .
+ */
+
+#include "PasskeyUtils.h"
+#include "BrowserMessageBuilder.h"
+#include "BrowserPasskeys.h"
+#include "core/Tools.h"
+#include "core/UrlTools.h"
+
+#include
+#include
+
+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;
+}
diff --git a/src/browser/PasskeyUtils.h b/src/browser/PasskeyUtils.h
new file mode 100644
index 000000000..1a08e295a
--- /dev/null
+++ b/src/browser/PasskeyUtils.h
@@ -0,0 +1,74 @@
+/*
+ * Copyright (C) 2024 KeePassXC Team
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * 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 .
+ */
+
+#ifndef PASSKEYUTILS_H
+#define PASSKEYUTILS_H
+
+#include
+#include
+#include
+#include
+
+#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
diff --git a/src/core/Config.cpp b/src/core/Config.cpp
index ba7cccfd2..3075c1ee7 100644
--- a/src/core/Config.cpp
+++ b/src/core/Config.cpp
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2020 KeePassXC Team
+ * Copyright (C) 2024 KeePassXC Team
* Copyright (C) 2011 Felix Geyer
*
* This program is free software: you can redistribute it and/or modify
@@ -166,6 +166,7 @@ static const QHash 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
diff --git a/src/core/Config.h b/src/core/Config.h
index 344e5bae7..ad80efab2 100644
--- a/src/core/Config.h
+++ b/src/core/Config.h
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2020 KeePassXC Team
+ * Copyright (C) 2024 KeePassXC Team
* Copyright (C) 2011 Felix Geyer
*
* This program is free software: you can redistribute it and/or modify
@@ -145,6 +145,7 @@ public:
Browser_UseCustomBrowser,
Browser_CustomBrowserType,
Browser_CustomBrowserLocation,
+ Browser_AllowLocalhostWithPasskeys,
#ifdef QT_DEBUG
Browser_CustomExtensionId,
#endif
diff --git a/src/core/Tools.cpp b/src/core/Tools.cpp
index cefb0448d..81fdd8e39 100644
--- a/src/core/Tools.cpp
+++ b/src/core/Tools.cpp
@@ -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);
diff --git a/src/core/Tools.h b/src/core/Tools.h
index 85c1b53c0..61d93ffbd 100644
--- a/src/core/Tools.h
+++ b/src/core/Tools.h
@@ -1,6 +1,6 @@
/*
+ * Copyright (C) 2024 KeePassXC Team
* Copyright (C) 2012 Felix Geyer
- * Copyright (C) 2023 KeePassXC Team
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -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);
diff --git a/src/core/UrlTools.cpp b/src/core/UrlTools.cpp
index 7360b48ea..508bbefda 100644
--- a/src/core/UrlTools.cpp
+++ b/src/core/UrlTools.cpp
@@ -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();
+}
diff --git a/src/core/UrlTools.h b/src/core/UrlTools.h
index f4d47cc8a..9a229e39f 100644
--- a/src/core/UrlTools.h
+++ b/src/core/UrlTools.h
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2023 KeePassXC Team
+ * Copyright (C) 2024 KeePassXC Team
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -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;
diff --git a/src/gui/passkeys/PasskeyExporter.cpp b/src/gui/passkeys/PasskeyExporter.cpp
index 26b7191b0..e1483930f 100644
--- a/src/gui/passkeys/PasskeyExporter.cpp
+++ b/src/gui/passkeys/PasskeyExporter.cpp
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2023 KeePassXC Team
+ * Copyright (C) 2024 KeePassXC Team
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -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);
diff --git a/tests/TestPasskeys.cpp b/tests/TestPasskeys.cpp
index 136ce6bb6..9ab66d99b 100644
--- a/tests/TestPasskeys.cpp
+++ b/tests/TestPasskeys.cpp
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2023 KeePassXC Team
+ * Copyright (C) 2024 KeePassXC Team
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -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"));
+}
diff --git a/tests/TestPasskeys.h b/tests/TestPasskeys.h
index 3d702e84a..b3882804f 100644
--- a/tests/TestPasskeys.h
+++ b/tests/TestPasskeys.h
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2023 KeePassXC Team
+ * Copyright (C) 2024 KeePassXC Team
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -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
diff --git a/tests/TestTools.cpp b/tests/TestTools.cpp
index 56b3e593b..9aadfe0bf 100644
--- a/tests/TestTools.cpp
+++ b/tests/TestTools.cpp
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2023 KeePassXC Team
+ * Copyright (C) 2024 KeePassXC Team
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -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;
diff --git a/tests/TestTools.h b/tests/TestTools.h
index 377b00fdb..e8a44b8b3 100644
--- a/tests/TestTools.h
+++ b/tests/TestTools.h
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2023 KeePassXC Team
+ * Copyright (C) 2024 KeePassXC Team
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -27,6 +27,7 @@ private slots:
void testHumanReadableFileSize();
void testIsHex();
void testIsBase64();
+ void testIsAsciiString();
void testEnvSubstitute();
void testValidUuid();
void testBackupFilePatternSubstitution_data();
diff --git a/tests/TestUrlTools.cpp b/tests/TestUrlTools.cpp
index 0e3ef844e..bc6f3546b 100644
--- a/tests/TestUrlTools.cpp
+++ b/tests/TestUrlTools.cpp
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2023 KeePassXC Team
+ * Copyright (C) 2024 KeePassXC Team
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -35,6 +35,7 @@ void TestUrlTools::testTopLevelDomain()
QList> 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"));
+}
diff --git a/tests/TestUrlTools.h b/tests/TestUrlTools.h
index d26e47040..74e91c174 100644
--- a/tests/TestUrlTools.h
+++ b/tests/TestUrlTools.h
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2023 KeePassXC Team
+ * Copyright (C) 2024 KeePassXC Team
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -34,6 +34,7 @@ private slots:
void testIsIpAddress();
void testIsUrlIdentical();
void testIsUrlValid();
+ void testDomainHasIllegalCharacters();
private:
QPointer m_urlTools;