mirror of
https://github.com/keepassxreboot/keepassxc.git
synced 2025-08-18 19:18:29 -04:00
Passkeys improvements (#10318)
Refactors the Passkey implementation to include more checks and a structure that is more aligned with the official specification. Notable changes: - _BrowserService_ no longer does the checks by itself. A new class _BrowserPasskeysClient_ constructs the relevant objects, acting as a client. _BrowserService_ only acts as a bridge between the client and _BrowserPasskeys_ (authenticator) and calls the relevant popups for user interaction. - A new helper class _PasskeyUtils_ includes the actual checks and parses the objects. - _BrowserPasskeys_ is pretty much intact, but some functions have been moved to PasskeyUtils. - Fixes Ed25519 encoding in _BrowserCBOR_. - Adds new error messages. - User confirmation for Passkey retrieval is also asked even if `discouraged` is used. This goes against the specification, but currently there's no other way to verify the user. - `cross-platform` is also accepted for compatibility. This could be removed if there's a potential issue with it. - Extension data is now handled correctly during Authentication. - Allowed and excluded credentials are now handled correctly. - `KPEX_PASSKEY_GENERATED_USER_ID` is renamed to `KPEX_PASSKEY_CREDENTIAL_ID` - Adds a new option "Allow localhost with Passkeys" to Browser Integration -> Advanced tab. By default it's not allowed to access HTTP sites, but `http://localhost` can be allowed for debugging and testing purposes for local servers. - Add tag `Passkey` to a Passkey entry, or an entry with an imported Passkey. Fixes #10287.
This commit is contained in:
parent
dff2f186ce
commit
ac2b445db6
33 changed files with 1248 additions and 269 deletions
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
|
||||
* Copyright (C) 2024 KeePassXC Team <team@keepassxc.org>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -19,6 +19,7 @@
|
|||
#include "BrowserMessageBuilder.h"
|
||||
#ifdef WITH_XC_BROWSER_PASSKEYS
|
||||
#include "BrowserPasskeys.h"
|
||||
#include "PasskeyUtils.h"
|
||||
#endif
|
||||
#include "BrowserSettings.h"
|
||||
#include "core/Global.h"
|
||||
|
@ -541,7 +542,7 @@ QJsonObject BrowserAction::handlePasskeysGet(const QJsonObject& json, const QStr
|
|||
}
|
||||
|
||||
const auto origin = browserRequest.getString("origin");
|
||||
if (!origin.startsWith("https://")) {
|
||||
if (!passkeyUtils()->isOriginAllowedWithLocalhost(browserSettings()->allowLocalhostWithPasskeys(), origin)) {
|
||||
return getErrorReply(action, ERROR_PASSKEYS_INVALID_URL_PROVIDED);
|
||||
}
|
||||
|
||||
|
@ -574,8 +575,8 @@ QJsonObject BrowserAction::handlePasskeysRegister(const QJsonObject& json, const
|
|||
}
|
||||
|
||||
const auto origin = browserRequest.getString("origin");
|
||||
if (!origin.startsWith("https://")) {
|
||||
return getErrorReply(action, ERROR_KEEPASS_ACTION_CANCELLED_OR_DENIED);
|
||||
if (!passkeyUtils()->isOriginAllowedWithLocalhost(browserSettings()->allowLocalhostWithPasskeys(), origin)) {
|
||||
return getErrorReply(action, ERROR_PASSKEYS_INVALID_URL_PROVIDED);
|
||||
}
|
||||
|
||||
const auto keyList = getConnectionKeys(browserRequest);
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
|
||||
* Copyright (C) 2024 KeePassXC Team <team@keepassxc.org>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -48,6 +48,16 @@ QByteArray BrowserCbor::cborEncodeAttestation(const QByteArray& authData) const
|
|||
// https://w3c.github.io/webauthn/#authdata-attestedcredentialdata-credentialpublickey
|
||||
QByteArray BrowserCbor::cborEncodePublicKey(int alg, const QByteArray& first, const QByteArray& second) const
|
||||
{
|
||||
const auto keyType = getCoseKeyType(alg);
|
||||
if (keyType == 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const auto curveParameter = getCurveParameter(alg);
|
||||
if ((alg == WebAuthnAlgorithms::ES256 || alg == WebAuthnAlgorithms::EDDSA) && curveParameter == 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
QByteArray result;
|
||||
QCborStreamWriter writer(&result);
|
||||
|
||||
|
@ -56,7 +66,7 @@ QByteArray BrowserCbor::cborEncodePublicKey(int alg, const QByteArray& first, co
|
|||
|
||||
// Key type
|
||||
writer.append(1);
|
||||
writer.append(getCoseKeyType(alg));
|
||||
writer.append(keyType);
|
||||
|
||||
// Signature algorithm
|
||||
writer.append(3);
|
||||
|
@ -64,7 +74,7 @@ QByteArray BrowserCbor::cborEncodePublicKey(int alg, const QByteArray& first, co
|
|||
|
||||
// Curve parameter
|
||||
writer.append(-1);
|
||||
writer.append(getCurveParameter(alg));
|
||||
writer.append(curveParameter);
|
||||
|
||||
// Key x-coordinate
|
||||
writer.append(-2);
|
||||
|
@ -80,7 +90,7 @@ QByteArray BrowserCbor::cborEncodePublicKey(int alg, const QByteArray& first, co
|
|||
|
||||
// Key type
|
||||
writer.append(1);
|
||||
writer.append(getCoseKeyType(alg));
|
||||
writer.append(keyType);
|
||||
|
||||
// Signature algorithm
|
||||
writer.append(3);
|
||||
|
@ -96,21 +106,24 @@ QByteArray BrowserCbor::cborEncodePublicKey(int alg, const QByteArray& first, co
|
|||
|
||||
writer.endMap();
|
||||
} else if (alg == WebAuthnAlgorithms::EDDSA) {
|
||||
// https://www.rfc-editor.org/rfc/rfc8152#section-13.2
|
||||
writer.startMap(3);
|
||||
writer.startMap(4);
|
||||
|
||||
// Key type
|
||||
writer.append(1);
|
||||
writer.append(keyType);
|
||||
|
||||
// Algorithm
|
||||
writer.append(3);
|
||||
writer.append(alg);
|
||||
|
||||
// Curve parameter
|
||||
writer.append(-1);
|
||||
writer.append(getCurveParameter(alg));
|
||||
writer.append(curveParameter);
|
||||
|
||||
// Public key
|
||||
writer.append(-2);
|
||||
writer.append(first);
|
||||
|
||||
// Private key
|
||||
writer.append(-4);
|
||||
writer.append(second);
|
||||
|
||||
writer.endMap();
|
||||
}
|
||||
|
||||
|
@ -230,7 +243,7 @@ unsigned int BrowserCbor::getCurveParameter(int alg) const
|
|||
case WebAuthnAlgorithms::EDDSA:
|
||||
return WebAuthnCurveKey::ED25519;
|
||||
default:
|
||||
return WebAuthnCurveKey::P256;
|
||||
return WebAuthnCurveKey::INVALID_CURVE_KEY;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -240,14 +253,15 @@ unsigned int BrowserCbor::getCoseKeyType(int alg) const
|
|||
{
|
||||
switch (alg) {
|
||||
case WebAuthnAlgorithms::ES256:
|
||||
return WebAuthnCoseKeyType::EC2;
|
||||
case WebAuthnAlgorithms::ES384:
|
||||
case WebAuthnAlgorithms::ES512:
|
||||
return WebAuthnCoseKeyType::EC2;
|
||||
return WebAuthnCoseKeyType::INVALID_COSE_KEY_TYPE;
|
||||
case WebAuthnAlgorithms::EDDSA:
|
||||
return WebAuthnCoseKeyType::OKP;
|
||||
case WebAuthnAlgorithms::RS256:
|
||||
return WebAuthnCoseKeyType::RSA;
|
||||
default:
|
||||
return WebAuthnCoseKeyType::EC2;
|
||||
return WebAuthnCoseKeyType::INVALID_COSE_KEY_TYPE;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
|
||||
* Copyright (C) 2024 KeePassXC Team <team@keepassxc.org>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -35,6 +35,7 @@ enum WebAuthnAlgorithms : int
|
|||
// https://www.rfc-editor.org/rfc/rfc9053#section-7.1
|
||||
enum WebAuthnCurveKey : int
|
||||
{
|
||||
INVALID_CURVE_KEY = 0,
|
||||
P256 = 1, // EC2, NIST P-256, also known as secp256r1
|
||||
P384 = 2, // EC2, NIST P-384, also known as secp384r1
|
||||
P521 = 3, // EC2, NIST P-521, also known as secp521r1
|
||||
|
@ -48,6 +49,7 @@ enum WebAuthnCurveKey : int
|
|||
// For RSA: https://www.rfc-editor.org/rfc/rfc8230#section-4
|
||||
enum WebAuthnCoseKeyType : int
|
||||
{
|
||||
INVALID_COSE_KEY_TYPE = 0,
|
||||
OKP = 1, // Octet Keypair
|
||||
EC2 = 2, // Elliptic Curve
|
||||
RSA = 3 // RSA
|
||||
|
|
|
@ -140,8 +140,22 @@ QString BrowserMessageBuilder::getErrorMessage(const int errorCode) const
|
|||
return QObject::tr("Empty public key");
|
||||
case ERROR_PASSKEYS_INVALID_URL_PROVIDED:
|
||||
return QObject::tr("Invalid URL provided");
|
||||
case ERROR_PASSKEYS_RESIDENT_KEYS_NOT_SUPPORTED:
|
||||
return QObject::tr("Resident Keys are not supported");
|
||||
case ERROR_PASSKEYS_ORIGIN_NOT_ALLOWED:
|
||||
return QObject::tr("Origin is empty or not allowed");
|
||||
case ERROR_PASSKEYS_DOMAIN_IS_NOT_VALID:
|
||||
return QObject::tr("Effective domain is not a valid domain");
|
||||
case ERROR_PASSKEYS_DOMAIN_RPID_MISMATCH:
|
||||
return QObject::tr("Origin and RP ID do not match");
|
||||
case ERROR_PASSKEYS_NO_SUPPORTED_ALGORITHMS:
|
||||
return QObject::tr("No supported algorithms were provided");
|
||||
case ERROR_PASSKEYS_WAIT_FOR_LIFETIMER:
|
||||
return QObject::tr("Wait for timer to expire");
|
||||
case ERROR_PASSKEYS_UNKNOWN_ERROR:
|
||||
return QObject::tr("Unknown Passkeys error");
|
||||
case ERROR_PASSKEYS_INVALID_CHALLENGE:
|
||||
return QObject::tr("Challenge is shorter than required minimum length");
|
||||
case ERROR_PASSKEYS_INVALID_USER_ID:
|
||||
return QObject::tr("user.id does not match the required length");
|
||||
default:
|
||||
return QObject::tr("Unknown error");
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
|
||||
* Copyright (C) 2024 KeePassXC Team <team@keepassxc.org>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -55,7 +55,14 @@ namespace
|
|||
ERROR_PASSKEYS_INVALID_USER_VERIFICATION = 23,
|
||||
ERROR_PASSKEYS_EMPTY_PUBLIC_KEY = 24,
|
||||
ERROR_PASSKEYS_INVALID_URL_PROVIDED = 25,
|
||||
ERROR_PASSKEYS_RESIDENT_KEYS_NOT_SUPPORTED = 26,
|
||||
ERROR_PASSKEYS_ORIGIN_NOT_ALLOWED = 26,
|
||||
ERROR_PASSKEYS_DOMAIN_IS_NOT_VALID = 27,
|
||||
ERROR_PASSKEYS_DOMAIN_RPID_MISMATCH = 28,
|
||||
ERROR_PASSKEYS_NO_SUPPORTED_ALGORITHMS = 29,
|
||||
ERROR_PASSKEYS_WAIT_FOR_LIFETIMER = 30,
|
||||
ERROR_PASSKEYS_UNKNOWN_ERROR = 31,
|
||||
ERROR_PASSKEYS_INVALID_CHALLENGE = 32,
|
||||
ERROR_PASSKEYS_INVALID_USER_ID = 33,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
|
||||
* Copyright (C) 2024 KeePassXC Team <team@keepassxc.org>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -18,8 +18,8 @@
|
|||
#include "BrowserPasskeys.h"
|
||||
#include "BrowserMessageBuilder.h"
|
||||
#include "BrowserService.h"
|
||||
#include "PasskeyUtils.h"
|
||||
#include "crypto/Random.h"
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QtEndian>
|
||||
|
@ -40,6 +40,13 @@ Q_GLOBAL_STATIC(BrowserPasskeys, s_browserPasskeys);
|
|||
// KeePassXC AAGUID: fdb141b2-5d84-443e-8a35-4698c205a502
|
||||
const QString BrowserPasskeys::AAGUID = QStringLiteral("fdb141b25d84443e8a354698c205a502");
|
||||
|
||||
// Authenticator capabilities
|
||||
const QString BrowserPasskeys::ATTACHMENT_CROSS_PLATFORM = QStringLiteral("cross-platform");
|
||||
const QString BrowserPasskeys::ATTACHMENT_PLATFORM = QStringLiteral("platform");
|
||||
const QString BrowserPasskeys::AUTHENTICATOR_TRANSPORT = QStringLiteral("internal");
|
||||
const bool BrowserPasskeys::SUPPORT_RESIDENT_KEYS = true;
|
||||
const bool BrowserPasskeys::SUPPORT_USER_VERIFICATION = true;
|
||||
|
||||
const QString BrowserPasskeys::PUBLIC_KEY = QStringLiteral("public-key");
|
||||
const QString BrowserPasskeys::REQUIREMENT_DISCOURAGED = QStringLiteral("discouraged");
|
||||
const QString BrowserPasskeys::REQUIREMENT_PREFERRED = QStringLiteral("preferred");
|
||||
|
@ -49,7 +56,7 @@ const QString BrowserPasskeys::PASSKEYS_ATTESTATION_DIRECT = QStringLiteral("dir
|
|||
const QString BrowserPasskeys::PASSKEYS_ATTESTATION_NONE = QStringLiteral("none");
|
||||
|
||||
const QString BrowserPasskeys::KPEX_PASSKEY_USERNAME = QStringLiteral("KPEX_PASSKEY_USERNAME");
|
||||
const QString BrowserPasskeys::KPEX_PASSKEY_GENERATED_USER_ID = QStringLiteral("KPEX_PASSKEY_GENERATED_USER_ID");
|
||||
const QString BrowserPasskeys::KPEX_PASSKEY_CREDENTIAL_ID = QStringLiteral("KPEX_PASSKEY_CREDENTIAL_ID");
|
||||
const QString BrowserPasskeys::KPEX_PASSKEY_PRIVATE_KEY_PEM = QStringLiteral("KPEX_PASSKEY_PRIVATE_KEY_PEM");
|
||||
const QString BrowserPasskeys::KPEX_PASSKEY_RELYING_PARTY = QStringLiteral("KPEX_PASSKEY_RELYING_PARTY");
|
||||
const QString BrowserPasskeys::KPEX_PASSKEY_USER_HANDLE = QStringLiteral("KPEX_PASSKEY_USER_HANDLE");
|
||||
|
@ -59,56 +66,80 @@ BrowserPasskeys* BrowserPasskeys::instance()
|
|||
return s_browserPasskeys;
|
||||
}
|
||||
|
||||
PublicKeyCredential BrowserPasskeys::buildRegisterPublicKeyCredential(const QJsonObject& publicKeyCredentialOptions,
|
||||
const QString& origin,
|
||||
PublicKeyCredential BrowserPasskeys::buildRegisterPublicKeyCredential(const QJsonObject& credentialCreationOptions,
|
||||
const TestingVariables& testingVariables)
|
||||
{
|
||||
QJsonObject publicKeyCredential;
|
||||
if (!passkeyUtils()->checkCredentialCreationOptions(credentialCreationOptions)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const auto authenticatorAttachment = credentialCreationOptions["authenticatorAttachment"];
|
||||
const auto clientDataJson = credentialCreationOptions["clientDataJSON"].toObject();
|
||||
const auto extensions = credentialCreationOptions["extensions"].toString();
|
||||
const auto credentialId = testingVariables.credentialId.isEmpty()
|
||||
? browserMessageBuilder()->getRandomBytesAsBase64(ID_BYTES)
|
||||
: testingVariables.credentialId;
|
||||
|
||||
// Extensions
|
||||
auto extensionObject = publicKeyCredentialOptions["extensions"].toObject();
|
||||
const auto extensionData = buildExtensionData(extensionObject);
|
||||
const auto extensions = browserMessageBuilder()->getBase64FromArray(extensionData);
|
||||
// Credential private key
|
||||
const auto alg = getAlgorithmFromPublicKey(credentialCreationOptions);
|
||||
const auto privateKey = buildCredentialPrivateKey(alg, testingVariables.first, testingVariables.second);
|
||||
if (privateKey.cborEncodedPublicKey.isEmpty() && privateKey.privateKeyPem.isEmpty()) {
|
||||
// Key creation failed
|
||||
return {};
|
||||
}
|
||||
|
||||
// Attestation
|
||||
const auto attestationObject = buildAttestationObject(
|
||||
credentialCreationOptions, extensions, credentialId, privateKey.cborEncodedPublicKey, testingVariables);
|
||||
if (attestationObject.isEmpty()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Response
|
||||
QJsonObject responseObject;
|
||||
const auto clientData = buildClientDataJson(publicKeyCredentialOptions, origin, false);
|
||||
const auto attestationObject =
|
||||
buildAttestationObject(publicKeyCredentialOptions, extensions, credentialId, testingVariables);
|
||||
responseObject["clientDataJSON"] = browserMessageBuilder()->getBase64FromJson(clientData);
|
||||
responseObject["attestationObject"] = browserMessageBuilder()->getBase64FromArray(attestationObject.cborEncoded);
|
||||
responseObject["attestationObject"] = browserMessageBuilder()->getBase64FromArray(attestationObject);
|
||||
responseObject["clientDataJSON"] = browserMessageBuilder()->getBase64FromJson(clientDataJson);
|
||||
|
||||
// PublicKeyCredential
|
||||
publicKeyCredential["authenticatorAttachment"] = QString("platform");
|
||||
QJsonObject publicKeyCredential;
|
||||
publicKeyCredential["authenticatorAttachment"] = authenticatorAttachment;
|
||||
publicKeyCredential["id"] = credentialId;
|
||||
publicKeyCredential["response"] = responseObject;
|
||||
publicKeyCredential["type"] = PUBLIC_KEY;
|
||||
|
||||
return {credentialId, publicKeyCredential, attestationObject.pem};
|
||||
PublicKeyCredential result;
|
||||
result.credentialId = credentialId;
|
||||
result.key = privateKey.privateKeyPem;
|
||||
result.response = publicKeyCredential;
|
||||
return result;
|
||||
}
|
||||
|
||||
QJsonObject BrowserPasskeys::buildGetPublicKeyCredential(const QJsonObject& publicKeyCredentialRequestOptions,
|
||||
const QString& origin,
|
||||
QJsonObject BrowserPasskeys::buildGetPublicKeyCredential(const QJsonObject& assertionOptions,
|
||||
const QString& credentialId,
|
||||
const QString& userHandle,
|
||||
const QString& privateKeyPem)
|
||||
{
|
||||
const auto authenticatorData = buildGetAttestationObject(publicKeyCredentialRequestOptions);
|
||||
const auto clientData = buildClientDataJson(publicKeyCredentialRequestOptions, origin, true);
|
||||
const auto clientDataArray = QJsonDocument(clientData).toJson(QJsonDocument::Compact);
|
||||
if (!passkeyUtils()->checkCredentialAssertionOptions(assertionOptions)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const auto authenticatorData = buildAuthenticatorData(assertionOptions);
|
||||
const auto clientDataJson = assertionOptions["clientDataJson"].toObject();
|
||||
const auto clientDataArray = QJsonDocument(clientDataJson).toJson(QJsonDocument::Compact);
|
||||
|
||||
const auto signature = buildSignature(authenticatorData, clientDataArray, privateKeyPem);
|
||||
if (signature.isEmpty()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
QJsonObject responseObject;
|
||||
responseObject["authenticatorData"] = browserMessageBuilder()->getBase64FromArray(authenticatorData);
|
||||
responseObject["clientDataJSON"] = browserMessageBuilder()->getBase64FromArray(clientDataArray);
|
||||
responseObject["clientDataJSON"] = browserMessageBuilder()->getBase64FromJson(clientDataJson);
|
||||
responseObject["signature"] = browserMessageBuilder()->getBase64FromArray(signature);
|
||||
responseObject["userHandle"] = userHandle;
|
||||
|
||||
QJsonObject publicKeyCredential;
|
||||
publicKeyCredential["authenticatorAttachment"] = QString("platform");
|
||||
publicKeyCredential["authenticatorAttachment"] = BrowserPasskeys::ATTACHMENT_PLATFORM;
|
||||
publicKeyCredential["id"] = credentialId;
|
||||
publicKeyCredential["response"] = responseObject;
|
||||
publicKeyCredential["type"] = PUBLIC_KEY;
|
||||
|
@ -116,68 +147,22 @@ QJsonObject BrowserPasskeys::buildGetPublicKeyCredential(const QJsonObject& publ
|
|||
return publicKeyCredential;
|
||||
}
|
||||
|
||||
bool BrowserPasskeys::isUserVerificationValid(const QString& userVerification) const
|
||||
{
|
||||
return QStringList({REQUIREMENT_PREFERRED, REQUIREMENT_REQUIRED, REQUIREMENT_DISCOURAGED})
|
||||
.contains(userVerification);
|
||||
}
|
||||
|
||||
// See https://w3c.github.io/webauthn/#sctn-createCredential for default timeout values when not set in the request
|
||||
int BrowserPasskeys::getTimeout(const QString& userVerification, int timeout) const
|
||||
{
|
||||
if (timeout == 0) {
|
||||
return userVerification == REQUIREMENT_DISCOURAGED ? DEFAULT_DISCOURAGED_TIMEOUT : DEFAULT_TIMEOUT;
|
||||
}
|
||||
|
||||
return timeout;
|
||||
}
|
||||
|
||||
QStringList BrowserPasskeys::getAllowedCredentialsFromPublicKey(const QJsonObject& publicKey) const
|
||||
{
|
||||
QStringList allowedCredentials;
|
||||
for (const auto& cred : publicKey["allowCredentials"].toArray()) {
|
||||
const auto c = cred.toObject();
|
||||
const auto id = c["id"].toString();
|
||||
|
||||
if (c["type"].toString() == PUBLIC_KEY && !id.isEmpty()) {
|
||||
allowedCredentials << id;
|
||||
}
|
||||
}
|
||||
|
||||
return allowedCredentials;
|
||||
}
|
||||
|
||||
QJsonObject BrowserPasskeys::buildClientDataJson(const QJsonObject& publicKey, const QString& origin, bool get)
|
||||
{
|
||||
QJsonObject clientData;
|
||||
clientData["challenge"] = publicKey["challenge"];
|
||||
clientData["crossOrigin"] = false;
|
||||
clientData["origin"] = origin;
|
||||
clientData["type"] = get ? QString("webauthn.get") : QString("webauthn.create");
|
||||
|
||||
return clientData;
|
||||
}
|
||||
|
||||
// https://w3c.github.io/webauthn/#attestation-object
|
||||
PrivateKey BrowserPasskeys::buildAttestationObject(const QJsonObject& publicKey,
|
||||
QByteArray BrowserPasskeys::buildAttestationObject(const QJsonObject& credentialCreationOptions,
|
||||
const QString& extensions,
|
||||
const QString& credentialId,
|
||||
const QByteArray& cborEncodedPublicKey,
|
||||
const TestingVariables& testingVariables)
|
||||
{
|
||||
QByteArray result;
|
||||
|
||||
// Create SHA256 hash from rpId
|
||||
const auto rpIdHash = browserMessageBuilder()->getSha256Hash(publicKey["rp"]["id"].toString());
|
||||
const auto rpIdHash = browserMessageBuilder()->getSha256Hash(credentialCreationOptions["rp"]["id"].toString());
|
||||
result.append(rpIdHash);
|
||||
|
||||
// Use default flags
|
||||
const auto flags =
|
||||
setFlagsFromJson(QJsonObject({{"ED", !extensions.isEmpty()},
|
||||
{"AT", true},
|
||||
{"BS", false},
|
||||
{"BE", false},
|
||||
{"UV", publicKey["userVerification"].toString() != REQUIREMENT_DISCOURAGED},
|
||||
{"UP", true}}));
|
||||
const auto flags = setFlagsFromJson(QJsonObject(
|
||||
{{"ED", !extensions.isEmpty()}, {"AT", true}, {"BS", false}, {"BE", false}, {"UV", true}, {"UP", true}}));
|
||||
result.append(flags);
|
||||
|
||||
// Signature counter (not supported, always 0
|
||||
|
@ -188,7 +173,7 @@ PrivateKey BrowserPasskeys::buildAttestationObject(const QJsonObject& publicKey,
|
|||
result.append(browserMessageBuilder()->getArrayFromHexString(AAGUID));
|
||||
|
||||
// Credential length
|
||||
const char credentialLength[2] = {0x00, 0x20};
|
||||
const char credentialLength[2] = {0x00, ID_BYTES};
|
||||
result.append(QByteArray::fromRawData(credentialLength, 2));
|
||||
|
||||
// Credential Id
|
||||
|
@ -196,10 +181,8 @@ PrivateKey BrowserPasskeys::buildAttestationObject(const QJsonObject& publicKey,
|
|||
testingVariables.credentialId.isEmpty() ? credentialId.toUtf8() : testingVariables.credentialId.toUtf8(),
|
||||
QByteArray::Base64UrlEncoding));
|
||||
|
||||
// Credential private key
|
||||
const auto alg = getAlgorithmFromPublicKey(publicKey);
|
||||
const auto credentialPublicKey = buildCredentialPrivateKey(alg, testingVariables.first, testingVariables.second);
|
||||
result.append(credentialPublicKey.cborEncoded);
|
||||
// Credential public key
|
||||
result.append(cborEncodedPublicKey);
|
||||
|
||||
// Add extension data if available
|
||||
if (!extensions.isEmpty()) {
|
||||
|
@ -207,35 +190,35 @@ PrivateKey BrowserPasskeys::buildAttestationObject(const QJsonObject& publicKey,
|
|||
}
|
||||
|
||||
// The final result should be CBOR encoded
|
||||
return {m_browserCbor.cborEncodeAttestation(result), credentialPublicKey.pem};
|
||||
return m_browserCbor.cborEncodeAttestation(result);
|
||||
}
|
||||
|
||||
// Build a short version of the attestation object for webauthn.get
|
||||
QByteArray BrowserPasskeys::buildGetAttestationObject(const QJsonObject& publicKey)
|
||||
QByteArray BrowserPasskeys::buildAuthenticatorData(const QJsonObject& publicKey)
|
||||
{
|
||||
QByteArray result;
|
||||
|
||||
const auto rpIdHash = browserMessageBuilder()->getSha256Hash(publicKey["rpId"].toString());
|
||||
result.append(rpIdHash);
|
||||
|
||||
const auto flags =
|
||||
setFlagsFromJson(QJsonObject({{"ED", false},
|
||||
{"AT", false},
|
||||
{"BS", false},
|
||||
{"BE", false},
|
||||
{"UV", publicKey["userVerification"].toString() != REQUIREMENT_DISCOURAGED},
|
||||
{"UP", true}}));
|
||||
const auto extensions = publicKey["extensions"].toString();
|
||||
const auto flags = setFlagsFromJson(QJsonObject(
|
||||
{{"ED", !extensions.isEmpty()}, {"AT", false}, {"BS", false}, {"BE", false}, {"UV", true}, {"UP", true}}));
|
||||
result.append(flags);
|
||||
|
||||
// Signature counter (not supported, always 0
|
||||
const char counter[4] = {0x00, 0x00, 0x00, 0x00};
|
||||
result.append(QByteArray::fromRawData(counter, 4));
|
||||
|
||||
if (!extensions.isEmpty()) {
|
||||
result.append(browserMessageBuilder()->getArrayFromBase64(extensions));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// See: https://w3c.github.io/webauthn/#sctn-encoded-credPubKey-examples
|
||||
PrivateKey
|
||||
AttestationKeyPair
|
||||
BrowserPasskeys::buildCredentialPrivateKey(int alg, const QString& predefinedFirst, const QString& predefinedSecond)
|
||||
{
|
||||
// Only support -7, P256 (EC), -8 (EdDSA) and -257 (RSA) for now
|
||||
|
@ -299,7 +282,14 @@ BrowserPasskeys::buildCredentialPrivateKey(int alg, const QString& predefinedFir
|
|||
}
|
||||
|
||||
auto result = m_browserCbor.cborEncodePublicKey(alg, firstPart, secondPart);
|
||||
return {result, pem};
|
||||
if (result.isEmpty()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
AttestationKeyPair attestationKeyPair;
|
||||
attestationKeyPair.cborEncodedPublicKey = result;
|
||||
attestationKeyPair.privateKeyPem = pem;
|
||||
return attestationKeyPair;
|
||||
}
|
||||
|
||||
QByteArray BrowserPasskeys::buildSignature(const QByteArray& authenticatorData,
|
||||
|
@ -339,7 +329,8 @@ QByteArray BrowserPasskeys::buildSignature(const QByteArray& authenticatorData,
|
|||
rawSignature = signer.signature(*randomGen()->getRng());
|
||||
} else if (algName == "Ed25519") {
|
||||
Botan::Ed25519_PrivateKey privateKey(algId, privateKeyBytes);
|
||||
Botan::PK_Signer signer(privateKey, *randomGen()->getRng(), "SHA-512");
|
||||
// "Pure" here means signing message directly. SHA-512 is only used with pre-hashed Ed25519 (Ed25519ph).
|
||||
Botan::PK_Signer signer(privateKey, *randomGen()->getRng(), "Pure");
|
||||
|
||||
signer.update(reinterpret_cast<const uint8_t*>(attToBeSigned.constData()), attToBeSigned.size());
|
||||
rawSignature = signer.signature(*randomGen()->getRng());
|
||||
|
@ -356,26 +347,6 @@ QByteArray BrowserPasskeys::buildSignature(const QByteArray& authenticatorData,
|
|||
}
|
||||
}
|
||||
|
||||
QByteArray BrowserPasskeys::buildExtensionData(QJsonObject& extensionObject) const
|
||||
{
|
||||
// Only supports "credProps" and "uvm" for now
|
||||
const QStringList allowedKeys = {"credProps", "uvm"};
|
||||
|
||||
// Remove unsupported keys
|
||||
for (const auto& key : extensionObject.keys()) {
|
||||
if (!allowedKeys.contains(key)) {
|
||||
extensionObject.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
auto extensionData = m_browserCbor.cborEncodeExtensionData(extensionObject);
|
||||
if (!extensionData.isEmpty()) {
|
||||
return extensionData;
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
// Parse authentication data byte array to JSON
|
||||
// See: https://www.w3.org/TR/webauthn/images/fido-attestation-structures.svg
|
||||
// And: https://w3c.github.io/webauthn/#attested-credential-data
|
||||
|
@ -420,6 +391,9 @@ QJsonObject BrowserPasskeys::parseFlags(const QByteArray& flags) const
|
|||
{"UP", flagBits.test(AuthenticatorFlags::UP)}});
|
||||
}
|
||||
|
||||
// https://w3c.github.io/webauthn/#table-authData
|
||||
// ED - Extension Data, AT - Attested Credential, BS - Reserved
|
||||
// BE - Reserved , UV - User Verified, UP - User Present
|
||||
char BrowserPasskeys::setFlagsFromJson(const QJsonObject& flags) const
|
||||
{
|
||||
if (flags.isEmpty()) {
|
||||
|
@ -444,9 +418,9 @@ char BrowserPasskeys::setFlagsFromJson(const QJsonObject& flags) const
|
|||
}
|
||||
|
||||
// Returns the first supported algorithm from the pubKeyCredParams list (only support ES256, RS256 and EdDSA for now)
|
||||
WebAuthnAlgorithms BrowserPasskeys::getAlgorithmFromPublicKey(const QJsonObject& publicKey) const
|
||||
WebAuthnAlgorithms BrowserPasskeys::getAlgorithmFromPublicKey(const QJsonObject& credentialCreationOptions) const
|
||||
{
|
||||
const auto pubKeyCredParams = publicKey["pubKeyCredParams"].toArray();
|
||||
const auto pubKeyCredParams = credentialCreationOptions["credTypesAndPubKeyAlgs"].toArray();
|
||||
if (!pubKeyCredParams.isEmpty()) {
|
||||
const auto alg = pubKeyCredParams.first()["alg"].toInt();
|
||||
if (alg == WebAuthnAlgorithms::ES256 || alg == WebAuthnAlgorithms::RS256 || alg == WebAuthnAlgorithms::EDDSA) {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
|
||||
* Copyright (C) 2024 KeePassXC Team <team@keepassxc.org>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -27,8 +27,6 @@
|
|||
|
||||
#define ID_BYTES 32
|
||||
#define HASH_BYTES 32
|
||||
#define DEFAULT_TIMEOUT 300000
|
||||
#define DEFAULT_DISCOURAGED_TIMEOUT 120000
|
||||
#define RSA_BITS 2048
|
||||
#define RSA_EXPONENT 65537
|
||||
|
||||
|
@ -59,10 +57,10 @@ struct PublicKeyCredential
|
|||
QByteArray key;
|
||||
};
|
||||
|
||||
struct PrivateKey
|
||||
struct AttestationKeyPair
|
||||
{
|
||||
QByteArray cborEncoded;
|
||||
QByteArray pem;
|
||||
QByteArray cborEncodedPublicKey;
|
||||
QByteArray privateKeyPem;
|
||||
};
|
||||
|
||||
// Predefined variables used for testing the class
|
||||
|
@ -82,19 +80,21 @@ public:
|
|||
~BrowserPasskeys() = default;
|
||||
static BrowserPasskeys* instance();
|
||||
|
||||
PublicKeyCredential buildRegisterPublicKeyCredential(const QJsonObject& publicKeyCredentialOptions,
|
||||
const QString& origin,
|
||||
PublicKeyCredential buildRegisterPublicKeyCredential(const QJsonObject& credentialCreationOptions,
|
||||
const TestingVariables& predefinedVariables = {});
|
||||
QJsonObject buildGetPublicKeyCredential(const QJsonObject& publicKeyCredentialRequestOptions,
|
||||
const QString& origin,
|
||||
QJsonObject buildGetPublicKeyCredential(const QJsonObject& assertionOptions,
|
||||
const QString& credentialId,
|
||||
const QString& userHandle,
|
||||
const QString& privateKeyPem);
|
||||
bool isUserVerificationValid(const QString& userVerification) const;
|
||||
int getTimeout(const QString& userVerification, int timeout) const;
|
||||
QStringList getAllowedCredentialsFromPublicKey(const QJsonObject& publicKey) const;
|
||||
|
||||
static const QString AAGUID;
|
||||
|
||||
static const QString ATTACHMENT_CROSS_PLATFORM;
|
||||
static const QString ATTACHMENT_PLATFORM;
|
||||
static const QString AUTHENTICATOR_TRANSPORT;
|
||||
static const bool SUPPORT_RESIDENT_KEYS;
|
||||
static const bool SUPPORT_USER_VERIFICATION;
|
||||
|
||||
static const QString PUBLIC_KEY;
|
||||
static const QString REQUIREMENT_DISCOURAGED;
|
||||
static const QString REQUIREMENT_PREFERRED;
|
||||
|
@ -104,28 +104,27 @@ public:
|
|||
static const QString PASSKEYS_ATTESTATION_NONE;
|
||||
|
||||
static const QString KPEX_PASSKEY_USERNAME;
|
||||
static const QString KPEX_PASSKEY_GENERATED_USER_ID;
|
||||
static const QString KPEX_PASSKEY_CREDENTIAL_ID;
|
||||
static const QString KPEX_PASSKEY_PRIVATE_KEY_PEM;
|
||||
static const QString KPEX_PASSKEY_RELYING_PARTY;
|
||||
static const QString KPEX_PASSKEY_USER_HANDLE;
|
||||
|
||||
private:
|
||||
QJsonObject buildClientDataJson(const QJsonObject& publicKey, const QString& origin, bool get);
|
||||
PrivateKey buildAttestationObject(const QJsonObject& publicKey,
|
||||
QByteArray buildAttestationObject(const QJsonObject& credentialCreationOptions,
|
||||
const QString& extensions,
|
||||
const QString& credentialId,
|
||||
const QByteArray& cborEncodedPublicKey,
|
||||
const TestingVariables& predefinedVariables = {});
|
||||
QByteArray buildGetAttestationObject(const QJsonObject& publicKey);
|
||||
PrivateKey buildCredentialPrivateKey(int alg,
|
||||
const QString& predefinedFirst = QString(),
|
||||
const QString& predefinedSecond = QString());
|
||||
QByteArray buildAuthenticatorData(const QJsonObject& publicKey);
|
||||
AttestationKeyPair buildCredentialPrivateKey(int alg,
|
||||
const QString& predefinedFirst = QString(),
|
||||
const QString& predefinedSecond = QString());
|
||||
QByteArray
|
||||
buildSignature(const QByteArray& authenticatorData, const QByteArray& clientData, const QString& privateKeyPem);
|
||||
QByteArray buildExtensionData(QJsonObject& extensionObject) const;
|
||||
QJsonObject parseAuthData(const QByteArray& authData) const;
|
||||
QJsonObject parseFlags(const QByteArray& flags) const;
|
||||
char setFlagsFromJson(const QJsonObject& flags) const;
|
||||
WebAuthnAlgorithms getAlgorithmFromPublicKey(const QJsonObject& publicKey) const;
|
||||
WebAuthnAlgorithms getAlgorithmFromPublicKey(const QJsonObject& credentialCreationOptions) const;
|
||||
QByteArray bigIntToQByteArray(Botan::BigInt& bigInt) const;
|
||||
|
||||
Q_DISABLE_COPY(BrowserPasskeys);
|
||||
|
|
173
src/browser/BrowserPasskeysClient.cpp
Normal file
173
src/browser/BrowserPasskeysClient.cpp
Normal file
|
@ -0,0 +1,173 @@
|
|||
/*
|
||||
* Copyright (C) 2024 KeePassXC Team <team@keepassxc.org>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include "BrowserPasskeysClient.h"
|
||||
#include "BrowserMessageBuilder.h"
|
||||
#include "BrowserPasskeys.h"
|
||||
#include "PasskeyUtils.h"
|
||||
|
||||
#include <QJsonDocument>
|
||||
|
||||
Q_GLOBAL_STATIC(BrowserPasskeysClient, s_browserPasskeysClient);
|
||||
|
||||
BrowserPasskeysClient* BrowserPasskeysClient::instance()
|
||||
{
|
||||
return s_browserPasskeysClient;
|
||||
}
|
||||
|
||||
// Constructs CredentialCreationOptions from the original PublicKeyCredential
|
||||
// https://www.w3.org/TR/2019/REC-webauthn-1-20190304/#createCredential
|
||||
int BrowserPasskeysClient::getCredentialCreationOptions(const QJsonObject& publicKeyOptions,
|
||||
const QString& origin,
|
||||
QJsonObject* result) const
|
||||
{
|
||||
if (!result || publicKeyOptions.isEmpty()) {
|
||||
return ERROR_PASSKEYS_EMPTY_PUBLIC_KEY;
|
||||
}
|
||||
|
||||
// Check validity of some basic values
|
||||
const auto checkResultError = passkeyUtils()->checkLimits(publicKeyOptions);
|
||||
if (checkResultError > 0) {
|
||||
return checkResultError;
|
||||
}
|
||||
|
||||
// Get effective domain
|
||||
QString effectiveDomain;
|
||||
const auto effectiveDomainResponse = passkeyUtils()->getEffectiveDomain(origin, &effectiveDomain);
|
||||
if (effectiveDomainResponse > 0) {
|
||||
return effectiveDomainResponse;
|
||||
}
|
||||
|
||||
// Validate RP ID
|
||||
QString rpId;
|
||||
const auto rpName = publicKeyOptions["rp"]["name"].toString();
|
||||
const auto rpIdResponse = passkeyUtils()->validateRpId(publicKeyOptions["rp"]["id"], effectiveDomain, &rpId);
|
||||
if (rpIdResponse > 0) {
|
||||
return rpIdResponse;
|
||||
}
|
||||
|
||||
// Check PublicKeyCredentialTypes
|
||||
const auto pubKeyCredParams = passkeyUtils()->parseCredentialTypes(publicKeyOptions["pubKeyCredParams"].toArray());
|
||||
if (pubKeyCredParams.isEmpty() && !publicKeyOptions["pubKeyCredParams"].toArray().isEmpty()) {
|
||||
return ERROR_PASSKEYS_NO_SUPPORTED_ALGORITHMS;
|
||||
}
|
||||
|
||||
// Check Attestation
|
||||
const auto attestation = passkeyUtils()->parseAttestation(publicKeyOptions["attestation"].toString());
|
||||
|
||||
// Check validity of AuthenticatorSelection
|
||||
auto authenticatorSelection = publicKeyOptions["authenticatorSelection"].toObject();
|
||||
const bool isAuthenticatorSelectionValid = passkeyUtils()->isAuthenticatorSelectionValid(authenticatorSelection);
|
||||
if (!isAuthenticatorSelectionValid) {
|
||||
return ERROR_PASSKEYS_WAIT_FOR_LIFETIMER;
|
||||
}
|
||||
|
||||
// Add default values for compatibility
|
||||
if (authenticatorSelection.isEmpty()) {
|
||||
authenticatorSelection = QJsonObject({{"userVerification", BrowserPasskeys::REQUIREMENT_PREFERRED}});
|
||||
} else if (authenticatorSelection["userVerification"].toString().isEmpty()) {
|
||||
authenticatorSelection["userVerification"] = BrowserPasskeys::REQUIREMENT_PREFERRED;
|
||||
}
|
||||
|
||||
auto authenticatorAttachment = authenticatorSelection["authenticatorAttachment"].toString();
|
||||
if (authenticatorAttachment.isEmpty()) {
|
||||
authenticatorAttachment = BrowserPasskeys::ATTACHMENT_PLATFORM;
|
||||
}
|
||||
|
||||
// Unknown values are ignored, but a warning will be still shown just in case
|
||||
const auto userVerification = authenticatorSelection["userVerification"].toString();
|
||||
if (!passkeyUtils()->isUserVerificationValid(userVerification)) {
|
||||
qWarning() << browserMessageBuilder()->getErrorMessage(ERROR_PASSKEYS_INVALID_USER_VERIFICATION);
|
||||
}
|
||||
|
||||
// Parse requireResidentKey and userVerification
|
||||
const auto isResidentKeyRequired = passkeyUtils()->isResidentKeyRequired(authenticatorSelection);
|
||||
const auto isUserVerificationRequired = passkeyUtils()->isUserVerificationRequired(authenticatorSelection);
|
||||
|
||||
// Extensions
|
||||
auto extensionObject = publicKeyOptions["extensions"].toObject();
|
||||
const auto extensionData = passkeyUtils()->buildExtensionData(extensionObject);
|
||||
const auto extensions = browserMessageBuilder()->getBase64FromArray(extensionData);
|
||||
|
||||
// Construct the final object
|
||||
QJsonObject credentialCreationOptions;
|
||||
credentialCreationOptions["attestation"] = attestation; // Set this, even if only "none" is supported
|
||||
credentialCreationOptions["authenticatorAttachment"] = authenticatorAttachment;
|
||||
credentialCreationOptions["clientDataJSON"] = passkeyUtils()->buildClientDataJson(publicKeyOptions, origin, false);
|
||||
credentialCreationOptions["credTypesAndPubKeyAlgs"] = pubKeyCredParams;
|
||||
credentialCreationOptions["excludeCredentials"] = publicKeyOptions["excludeCredentials"];
|
||||
credentialCreationOptions["extensions"] = extensions;
|
||||
credentialCreationOptions["residentKey"] = isResidentKeyRequired;
|
||||
credentialCreationOptions["rp"] = QJsonObject({{"id", rpId}, {"name", rpName}});
|
||||
credentialCreationOptions["user"] = publicKeyOptions["user"];
|
||||
credentialCreationOptions["userPresence"] = !isUserVerificationRequired;
|
||||
credentialCreationOptions["userVerification"] = isUserVerificationRequired;
|
||||
|
||||
*result = credentialCreationOptions;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Use an existing credential
|
||||
// https://www.w3.org/TR/2019/REC-webauthn-1-20190304/#getAssertion
|
||||
int BrowserPasskeysClient::getAssertionOptions(const QJsonObject& publicKeyOptions,
|
||||
const QString& origin,
|
||||
QJsonObject* result) const
|
||||
{
|
||||
if (!result || publicKeyOptions.isEmpty()) {
|
||||
return ERROR_PASSKEYS_EMPTY_PUBLIC_KEY;
|
||||
}
|
||||
|
||||
// Get effective domain
|
||||
QString effectiveDomain;
|
||||
const auto effectiveDomainResponse = passkeyUtils()->getEffectiveDomain(origin, &effectiveDomain);
|
||||
if (effectiveDomainResponse > 0) {
|
||||
return effectiveDomainResponse;
|
||||
}
|
||||
|
||||
// Validate RP ID
|
||||
QString rpId;
|
||||
const auto rpIdResponse = passkeyUtils()->validateRpId(publicKeyOptions["rpId"], effectiveDomain, &rpId);
|
||||
if (rpIdResponse > 0) {
|
||||
return rpIdResponse;
|
||||
}
|
||||
|
||||
// Extensions
|
||||
auto extensionObject = publicKeyOptions["extensions"].toObject();
|
||||
const auto extensionData = passkeyUtils()->buildExtensionData(extensionObject);
|
||||
const auto extensions = browserMessageBuilder()->getBase64FromArray(extensionData);
|
||||
|
||||
// clientDataJson
|
||||
const auto clientDataJson = passkeyUtils()->buildClientDataJson(publicKeyOptions, origin, true);
|
||||
|
||||
// Unknown values are ignored, but a warning will be still shown just in case
|
||||
const auto userVerification = publicKeyOptions["userVerification"].toString();
|
||||
if (!passkeyUtils()->isUserVerificationValid(userVerification)) {
|
||||
qWarning() << browserMessageBuilder()->getErrorMessage(ERROR_PASSKEYS_INVALID_USER_VERIFICATION);
|
||||
}
|
||||
const auto isUserVerificationRequired = passkeyUtils()->isUserVerificationRequired(publicKeyOptions);
|
||||
|
||||
QJsonObject assertionOptions;
|
||||
assertionOptions["allowCredentials"] = publicKeyOptions["allowCredentials"];
|
||||
assertionOptions["clientDataJson"] = clientDataJson;
|
||||
assertionOptions["extensions"] = extensions;
|
||||
assertionOptions["rpId"] = rpId;
|
||||
assertionOptions["userPresence"] = true;
|
||||
assertionOptions["userVerification"] = isUserVerificationRequired;
|
||||
|
||||
*result = assertionOptions;
|
||||
return 0;
|
||||
}
|
49
src/browser/BrowserPasskeysClient.h
Normal file
49
src/browser/BrowserPasskeysClient.h
Normal file
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* Copyright (C) 2024 KeePassXC Team <team@keepassxc.org>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#ifndef BROWSERPASSKEYSCLIENT_H
|
||||
#define BROWSERPASSKEYSCLIENT_H
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QObject>
|
||||
|
||||
class BrowserPasskeysClient : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit BrowserPasskeysClient() = default;
|
||||
~BrowserPasskeysClient() = default;
|
||||
static BrowserPasskeysClient* instance();
|
||||
|
||||
int
|
||||
getCredentialCreationOptions(const QJsonObject& publicKeyOptions, const QString& origin, QJsonObject* result) const;
|
||||
int getAssertionOptions(const QJsonObject& publicKeyOptions, const QString& origin, QJsonObject* result) const;
|
||||
|
||||
private:
|
||||
Q_DISABLE_COPY(BrowserPasskeysClient);
|
||||
|
||||
friend class TestPasskeys;
|
||||
};
|
||||
|
||||
static inline BrowserPasskeysClient* browserPasskeysClient()
|
||||
{
|
||||
return BrowserPasskeysClient::instance();
|
||||
}
|
||||
|
||||
#endif // BROWSERPASSKEYSCLIENT_H
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
|
||||
* Copyright (C) 2024 KeePassXC Team <team@keepassxc.org>
|
||||
* Copyright (C) 2017 Sami Vänttinen <sami.vanttinen@protonmail.com>
|
||||
* Copyright (C) 2013 Francois Ferrand
|
||||
*
|
||||
|
@ -31,7 +31,9 @@
|
|||
#include "gui/osutils/OSUtils.h"
|
||||
#ifdef WITH_XC_BROWSER_PASSKEYS
|
||||
#include "BrowserPasskeys.h"
|
||||
#include "BrowserPasskeysClient.h"
|
||||
#include "BrowserPasskeysConfirmationDialog.h"
|
||||
#include "PasskeyUtils.h"
|
||||
#endif
|
||||
#ifdef Q_OS_MACOS
|
||||
#include "gui/osutils/macutils/MacUtils.h"
|
||||
|
@ -611,7 +613,7 @@ QString BrowserService::getKey(const QString& id)
|
|||
|
||||
#ifdef WITH_XC_BROWSER_PASSKEYS
|
||||
// Passkey registration
|
||||
QJsonObject BrowserService::showPasskeysRegisterPrompt(const QJsonObject& publicKey,
|
||||
QJsonObject BrowserService::showPasskeysRegisterPrompt(const QJsonObject& publicKeyOptions,
|
||||
const QString& origin,
|
||||
const StringPairList& keyList)
|
||||
{
|
||||
|
@ -620,39 +622,23 @@ QJsonObject BrowserService::showPasskeysRegisterPrompt(const QJsonObject& public
|
|||
return getPasskeyError(ERROR_KEEPASS_DATABASE_NOT_OPENED);
|
||||
}
|
||||
|
||||
const auto userJson = publicKey["user"].toObject();
|
||||
const auto username = userJson["name"].toString();
|
||||
const auto userHandle = userJson["id"].toString();
|
||||
const auto rpId = publicKey["rp"]["id"].toString();
|
||||
const auto rpName = publicKey["rp"]["name"].toString();
|
||||
const auto timeoutValue = publicKey["timeout"].toInt();
|
||||
const auto excludeCredentials = publicKey["excludeCredentials"].toArray();
|
||||
const auto attestation = publicKey["attestation"].toString();
|
||||
|
||||
// Check Resident Key requirement
|
||||
const auto authenticatorSelection = publicKey["authenticatorSelection"].toObject();
|
||||
const auto requireResidentKey = authenticatorSelection["requireResidentKey"].toBool();
|
||||
if (requireResidentKey) {
|
||||
return getPasskeyError(ERROR_PASSKEYS_RESIDENT_KEYS_NOT_SUPPORTED);
|
||||
QJsonObject credentialCreationOptions;
|
||||
const auto pkOptionsResult =
|
||||
browserPasskeysClient()->getCredentialCreationOptions(publicKeyOptions, origin, &credentialCreationOptions);
|
||||
if (pkOptionsResult > 0 || credentialCreationOptions.isEmpty()) {
|
||||
return getPasskeyError(pkOptionsResult);
|
||||
}
|
||||
|
||||
// Only support these two for now
|
||||
if (attestation != BrowserPasskeys::PASSKEYS_ATTESTATION_NONE
|
||||
&& attestation != BrowserPasskeys::PASSKEYS_ATTESTATION_DIRECT) {
|
||||
return getPasskeyError(ERROR_PASSKEYS_ATTESTATION_NOT_SUPPORTED);
|
||||
}
|
||||
const auto excludeCredentials = credentialCreationOptions["excludeCredentials"].toArray();
|
||||
const auto rpId = publicKeyOptions["rp"]["id"].toString();
|
||||
const auto timeout = publicKeyOptions["timeout"].toInt();
|
||||
const auto username = credentialCreationOptions["user"].toObject()["name"].toString();
|
||||
|
||||
const auto userVerification = authenticatorSelection["userVerification"].toString();
|
||||
if (!browserPasskeys()->isUserVerificationValid(userVerification)) {
|
||||
return getPasskeyError(ERROR_PASSKEYS_INVALID_USER_VERIFICATION);
|
||||
}
|
||||
|
||||
if (!excludeCredentials.isEmpty() && isPasskeyCredentialExcluded(excludeCredentials, origin, keyList)) {
|
||||
// Parse excludeCredentialDescriptorList
|
||||
if (!excludeCredentials.isEmpty() && isPasskeyCredentialExcluded(excludeCredentials, rpId, keyList)) {
|
||||
return getPasskeyError(ERROR_PASSKEYS_CREDENTIAL_IS_EXCLUDED);
|
||||
}
|
||||
|
||||
const auto existingEntries = getPasskeyEntries(rpId, keyList);
|
||||
const auto timeout = browserPasskeys()->getTimeout(userVerification, timeoutValue);
|
||||
|
||||
raiseWindow();
|
||||
BrowserPasskeysConfirmationDialog confirmDialog;
|
||||
|
@ -660,7 +646,16 @@ QJsonObject BrowserService::showPasskeysRegisterPrompt(const QJsonObject& public
|
|||
|
||||
auto dialogResult = confirmDialog.exec();
|
||||
if (dialogResult == QDialog::Accepted) {
|
||||
const auto publicKeyCredentials = browserPasskeys()->buildRegisterPublicKeyCredential(publicKey, origin);
|
||||
const auto publicKeyCredentials =
|
||||
browserPasskeys()->buildRegisterPublicKeyCredential(credentialCreationOptions);
|
||||
if (publicKeyCredentials.credentialId.isEmpty() || publicKeyCredentials.key.isEmpty()
|
||||
|| publicKeyCredentials.response.isEmpty()) {
|
||||
return getPasskeyError(ERROR_PASSKEYS_UNKNOWN_ERROR);
|
||||
}
|
||||
|
||||
const auto rpName = publicKeyOptions["rp"]["name"].toString();
|
||||
const auto user = credentialCreationOptions["user"].toObject();
|
||||
const auto userId = user["id"].toString();
|
||||
|
||||
if (confirmDialog.isPasskeyUpdated()) {
|
||||
addPasskeyToEntry(confirmDialog.getSelectedEntry(),
|
||||
|
@ -668,7 +663,7 @@ QJsonObject BrowserService::showPasskeysRegisterPrompt(const QJsonObject& public
|
|||
rpName,
|
||||
username,
|
||||
publicKeyCredentials.credentialId,
|
||||
userHandle,
|
||||
userId,
|
||||
publicKeyCredentials.key);
|
||||
} else {
|
||||
addPasskeyToGroup(nullptr,
|
||||
|
@ -677,7 +672,7 @@ QJsonObject BrowserService::showPasskeysRegisterPrompt(const QJsonObject& public
|
|||
rpName,
|
||||
username,
|
||||
publicKeyCredentials.credentialId,
|
||||
userHandle,
|
||||
userId,
|
||||
publicKeyCredentials.key);
|
||||
}
|
||||
|
||||
|
@ -690,7 +685,7 @@ QJsonObject BrowserService::showPasskeysRegisterPrompt(const QJsonObject& public
|
|||
}
|
||||
|
||||
// Passkey authentication
|
||||
QJsonObject BrowserService::showPasskeysAuthenticationPrompt(const QJsonObject& publicKey,
|
||||
QJsonObject BrowserService::showPasskeysAuthenticationPrompt(const QJsonObject& publicKeyOptions,
|
||||
const QString& origin,
|
||||
const StringPairList& keyList)
|
||||
{
|
||||
|
@ -699,24 +694,21 @@ QJsonObject BrowserService::showPasskeysAuthenticationPrompt(const QJsonObject&
|
|||
return getPasskeyError(ERROR_KEEPASS_DATABASE_NOT_OPENED);
|
||||
}
|
||||
|
||||
const auto userVerification = publicKey["userVerification"].toString();
|
||||
if (!browserPasskeys()->isUserVerificationValid(userVerification)) {
|
||||
return getPasskeyError(ERROR_PASSKEYS_INVALID_USER_VERIFICATION);
|
||||
QJsonObject assertionOptions;
|
||||
const auto assertionResult =
|
||||
browserPasskeysClient()->getAssertionOptions(publicKeyOptions, origin, &assertionOptions);
|
||||
if (assertionResult > 0 || assertionOptions.isEmpty()) {
|
||||
return getPasskeyError(assertionResult);
|
||||
}
|
||||
|
||||
// Parse "allowCredentials"
|
||||
const auto rpId = publicKey["rpId"].toString();
|
||||
const auto entries = getPasskeyAllowedEntries(publicKey, rpId, keyList);
|
||||
// Get allowed entries from RP ID
|
||||
const auto rpId = assertionOptions["rpId"].toString();
|
||||
const auto entries = getPasskeyAllowedEntries(assertionOptions, rpId, keyList);
|
||||
if (entries.isEmpty()) {
|
||||
return getPasskeyError(ERROR_KEEPASS_NO_LOGINS_FOUND);
|
||||
}
|
||||
|
||||
// With single entry, if no verification is needed, return directly
|
||||
if (entries.count() == 1 && userVerification == BrowserPasskeys::REQUIREMENT_DISCOURAGED) {
|
||||
return getPublicKeyCredentialFromEntry(entries.first(), publicKey, origin);
|
||||
}
|
||||
|
||||
const auto timeout = browserPasskeys()->getTimeout(userVerification, publicKey["timeout"].toInt());
|
||||
const auto timeout = publicKeyOptions["timeout"].toInt();
|
||||
|
||||
raiseWindow();
|
||||
BrowserPasskeysConfirmationDialog confirmDialog;
|
||||
|
@ -725,7 +717,21 @@ QJsonObject BrowserService::showPasskeysAuthenticationPrompt(const QJsonObject&
|
|||
if (dialogResult == QDialog::Accepted) {
|
||||
hideWindow();
|
||||
const auto selectedEntry = confirmDialog.getSelectedEntry();
|
||||
return getPublicKeyCredentialFromEntry(selectedEntry, publicKey, origin);
|
||||
if (!selectedEntry) {
|
||||
return getPasskeyError(ERROR_PASSKEYS_UNKNOWN_ERROR);
|
||||
}
|
||||
|
||||
const auto privateKeyPem = selectedEntry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_PRIVATE_KEY_PEM);
|
||||
const auto credentialId = selectedEntry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_CREDENTIAL_ID);
|
||||
const auto userHandle = selectedEntry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_USER_HANDLE);
|
||||
|
||||
auto publicKeyCredential =
|
||||
browserPasskeys()->buildGetPublicKeyCredential(assertionOptions, credentialId, userHandle, privateKeyPem);
|
||||
if (publicKeyCredential.isEmpty()) {
|
||||
return getPasskeyError(ERROR_PASSKEYS_UNKNOWN_ERROR);
|
||||
}
|
||||
|
||||
return publicKeyCredential;
|
||||
}
|
||||
|
||||
hideWindow();
|
||||
|
@ -797,10 +803,11 @@ void BrowserService::addPasskeyToEntry(Entry* entry,
|
|||
entry->beginUpdate();
|
||||
|
||||
entry->attributes()->set(BrowserPasskeys::KPEX_PASSKEY_USERNAME, username);
|
||||
entry->attributes()->set(BrowserPasskeys::KPEX_PASSKEY_GENERATED_USER_ID, credentialId, true);
|
||||
entry->attributes()->set(BrowserPasskeys::KPEX_PASSKEY_CREDENTIAL_ID, credentialId, true);
|
||||
entry->attributes()->set(BrowserPasskeys::KPEX_PASSKEY_PRIVATE_KEY_PEM, privateKey, true);
|
||||
entry->attributes()->set(BrowserPasskeys::KPEX_PASSKEY_RELYING_PARTY, rpId);
|
||||
entry->attributes()->set(BrowserPasskeys::KPEX_PASSKEY_USER_HANDLE, userHandle, true);
|
||||
entry->addTag(tr("Passkey"));
|
||||
|
||||
entry->endUpdate();
|
||||
}
|
||||
|
@ -1342,18 +1349,21 @@ QList<Entry*> BrowserService::getPasskeyEntries(const QString& rpId, const Strin
|
|||
}
|
||||
|
||||
// Get all entries for the site that are allowed by the server
|
||||
QList<Entry*> BrowserService::getPasskeyAllowedEntries(const QJsonObject& publicKey,
|
||||
QList<Entry*> BrowserService::getPasskeyAllowedEntries(const QJsonObject& assertionOptions,
|
||||
const QString& rpId,
|
||||
const StringPairList& keyList)
|
||||
{
|
||||
QList<Entry*> entries;
|
||||
const auto allowedCredentials = browserPasskeys()->getAllowedCredentialsFromPublicKey(publicKey);
|
||||
const auto allowedCredentials = passkeyUtils()->getAllowedCredentialsFromAssertionOptions(assertionOptions);
|
||||
if (!assertionOptions["allowCredentials"].toArray().isEmpty() && allowedCredentials.isEmpty()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
for (const auto& entry : getPasskeyEntries(rpId, keyList)) {
|
||||
// If allowedCredentials.isEmpty() check if entry contains an extra attribute for user handle.
|
||||
// If that is found, the entry should be allowed.
|
||||
// See: https://w3c.github.io/webauthn/#dom-authenticatorassertionresponse-userhandle
|
||||
if (allowedCredentials.contains(entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_GENERATED_USER_ID))
|
||||
if (allowedCredentials.contains(entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_CREDENTIAL_ID))
|
||||
|| (allowedCredentials.isEmpty()
|
||||
&& entry->attributes()->hasKey(BrowserPasskeys::KPEX_PASSKEY_USER_HANDLE))) {
|
||||
entries << entry;
|
||||
|
@ -1363,18 +1373,9 @@ QList<Entry*> BrowserService::getPasskeyAllowedEntries(const QJsonObject& public
|
|||
return entries;
|
||||
}
|
||||
|
||||
QJsonObject
|
||||
BrowserService::getPublicKeyCredentialFromEntry(const Entry* entry, const QJsonObject& publicKey, const QString& origin)
|
||||
{
|
||||
const auto privateKeyPem = entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_PRIVATE_KEY_PEM);
|
||||
const auto credentialId = entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_GENERATED_USER_ID);
|
||||
const auto userHandle = entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_USER_HANDLE);
|
||||
return browserPasskeys()->buildGetPublicKeyCredential(publicKey, origin, credentialId, userHandle, privateKeyPem);
|
||||
}
|
||||
|
||||
// Checks if the same user ID already exists for the current site
|
||||
// Checks if the same user ID already exists for the current RP ID
|
||||
bool BrowserService::isPasskeyCredentialExcluded(const QJsonArray& excludeCredentials,
|
||||
const QString& origin,
|
||||
const QString& rpId,
|
||||
const StringPairList& keyList)
|
||||
{
|
||||
QStringList allIds;
|
||||
|
@ -1382,9 +1383,9 @@ bool BrowserService::isPasskeyCredentialExcluded(const QJsonArray& excludeCreden
|
|||
allIds << cred["id"].toString();
|
||||
}
|
||||
|
||||
const auto passkeyEntries = getPasskeyEntries(origin, keyList);
|
||||
const auto passkeyEntries = getPasskeyEntries(rpId, keyList);
|
||||
return std::any_of(passkeyEntries.begin(), passkeyEntries.end(), [&](const auto& entry) {
|
||||
return allIds.contains(entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_GENERATED_USER_ID));
|
||||
return allIds.contains(entry->attributes()->value(BrowserPasskeys::KPEX_PASSKEY_CREDENTIAL_ID));
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
|
||||
* Copyright (C) 2024 KeePassXC Team <team@keepassxc.org>
|
||||
* Copyright (C) 2017 Sami Vänttinen <sami.vanttinen@protonmail.com>
|
||||
* Copyright (C) 2013 Francois Ferrand
|
||||
*
|
||||
|
@ -88,9 +88,10 @@ public:
|
|||
QSharedPointer<Database> selectedDatabase();
|
||||
QList<QSharedPointer<Database>> getOpenDatabases();
|
||||
#ifdef WITH_XC_BROWSER_PASSKEYS
|
||||
QJsonObject
|
||||
showPasskeysRegisterPrompt(const QJsonObject& publicKey, const QString& origin, const StringPairList& keyList);
|
||||
QJsonObject showPasskeysAuthenticationPrompt(const QJsonObject& publicKey,
|
||||
QJsonObject showPasskeysRegisterPrompt(const QJsonObject& publicKeyOptions,
|
||||
const QString& origin,
|
||||
const StringPairList& keyList);
|
||||
QJsonObject showPasskeysAuthenticationPrompt(const QJsonObject& publicKeyOptions,
|
||||
const QString& origin,
|
||||
const StringPairList& keyList);
|
||||
void addPasskeyToGroup(Group* group,
|
||||
|
@ -177,18 +178,15 @@ private:
|
|||
Access checkAccess(const Entry* entry, const QString& siteHost, const QString& formHost, const QString& realm);
|
||||
Group* getDefaultEntryGroup(const QSharedPointer<Database>& selectedDb = {});
|
||||
int sortPriority(const QStringList& urls, const QString& siteUrl, const QString& formUrl);
|
||||
bool schemeFound(const QString& url);
|
||||
bool removeFirstDomain(QString& hostname);
|
||||
bool
|
||||
shouldIncludeEntry(Entry* entry, const QString& url, const QString& submitUrl, const bool omitWwwSubdomain = false);
|
||||
#ifdef WITH_XC_BROWSER_PASSKEYS
|
||||
QList<Entry*> getPasskeyEntries(const QString& rpId, const StringPairList& keyList);
|
||||
QList<Entry*>
|
||||
getPasskeyAllowedEntries(const QJsonObject& publicKey, const QString& rpId, const StringPairList& keyList);
|
||||
QJsonObject
|
||||
getPublicKeyCredentialFromEntry(const Entry* entry, const QJsonObject& publicKey, const QString& origin);
|
||||
getPasskeyAllowedEntries(const QJsonObject& assertionOptions, const QString& rpId, const StringPairList& keyList);
|
||||
bool isPasskeyCredentialExcluded(const QJsonArray& excludeCredentials,
|
||||
const QString& origin,
|
||||
const QString& rpId,
|
||||
const StringPairList& keyList);
|
||||
QJsonObject getPasskeyError(int errorCode) const;
|
||||
#endif
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
/*
|
||||
* Copyright (C) 2013 Francois Ferrand
|
||||
* Copyright (C) 2024 KeePassXC Team <team@keepassxc.org>
|
||||
* Copyright (C) 2017 Sami Vänttinen <sami.vanttinen@protonmail.com>
|
||||
* Copyright (C) 2021 KeePassXC Team <team@keepassxc.org>
|
||||
* Copyright (C) 2013 Francois Ferrand
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -145,6 +145,16 @@ void BrowserSettings::setNoMigrationPrompt(bool prompt)
|
|||
config()->set(Config::Browser_NoMigrationPrompt, prompt);
|
||||
}
|
||||
|
||||
bool BrowserSettings::allowLocalhostWithPasskeys()
|
||||
{
|
||||
return config()->get(Config::Browser_AllowLocalhostWithPasskeys).toBool();
|
||||
}
|
||||
|
||||
void BrowserSettings::setAllowLocalhostWithPasskeys(bool enabled)
|
||||
{
|
||||
config()->set(Config::Browser_AllowLocalhostWithPasskeys, enabled);
|
||||
}
|
||||
|
||||
bool BrowserSettings::useCustomProxy()
|
||||
{
|
||||
return config()->get(Config::Browser_UseCustomProxy).toBool();
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
/*
|
||||
* Copyright (C) 2013 Francois Ferrand
|
||||
* Copyright (C) 2024 KeePassXC Team <team@keepassxc.org>
|
||||
* Copyright (C) 2017 Sami Vänttinen <sami.vanttinen@protonmail.com>
|
||||
* Copyright (C) 2021 KeePassXC Team <team@keepassxc.org>
|
||||
* Copyright (C) 2013 Francois Ferrand
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
@ -51,6 +51,8 @@ public:
|
|||
void setSupportKphFields(bool supportKphFields);
|
||||
bool noMigrationPrompt();
|
||||
void setNoMigrationPrompt(bool prompt);
|
||||
bool allowLocalhostWithPasskeys();
|
||||
void setAllowLocalhostWithPasskeys(bool enabled);
|
||||
|
||||
bool useCustomProxy();
|
||||
void setUseCustomProxy(bool enabled);
|
||||
|
|
|
@ -123,6 +123,7 @@ void BrowserSettingsWidget::loadSettings()
|
|||
m_ui->httpAuthPermission->setChecked(settings->httpAuthPermission());
|
||||
m_ui->searchInAllDatabases->setChecked(settings->searchInAllDatabases());
|
||||
m_ui->supportKphFields->setChecked(settings->supportKphFields());
|
||||
m_ui->allowLocalhostWithPasskeys->setChecked(settings->allowLocalhostWithPasskeys());
|
||||
m_ui->noMigrationPrompt->setChecked(settings->noMigrationPrompt());
|
||||
m_ui->useCustomProxy->setChecked(settings->useCustomProxy());
|
||||
m_ui->customProxyLocation->setText(settings->replaceHomePath(settings->customProxyLocation()));
|
||||
|
@ -253,6 +254,7 @@ void BrowserSettingsWidget::saveSettings()
|
|||
settings->setHttpAuthPermission(m_ui->httpAuthPermission->isChecked());
|
||||
settings->setSearchInAllDatabases(m_ui->searchInAllDatabases->isChecked());
|
||||
settings->setSupportKphFields(m_ui->supportKphFields->isChecked());
|
||||
settings->setAllowLocalhostWithPasskeys(m_ui->allowLocalhostWithPasskeys->isChecked());
|
||||
settings->setNoMigrationPrompt(m_ui->noMigrationPrompt->isChecked());
|
||||
|
||||
#ifdef QT_DEBUG
|
||||
|
|
|
@ -310,6 +310,16 @@
|
|||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QCheckBox" name="allowLocalhostWithPasskeys">
|
||||
<property name="toolTip">
|
||||
<string>Allows using insecure http://localhost with Passkeys for testing purposes.</string>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Allow using localhost with Passkeys</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QCheckBox" name="noMigrationPrompt">
|
||||
<property name="toolTip">
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
|
||||
# Copyright (C) 2024 KeePassXC Team <team@keepassxc.org>
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
|
@ -35,7 +35,9 @@ if(WITH_XC_BROWSER)
|
|||
list(APPEND keepassxcbrowser_SOURCES
|
||||
BrowserCbor.cpp
|
||||
BrowserPasskeys.cpp
|
||||
BrowserPasskeysConfirmationDialog.cpp)
|
||||
BrowserPasskeysClient.cpp
|
||||
BrowserPasskeysConfirmationDialog.cpp
|
||||
PasskeyUtils.cpp)
|
||||
endif()
|
||||
|
||||
add_library(keepassxcbrowser STATIC ${keepassxcbrowser_SOURCES})
|
||||
|
|
352
src/browser/PasskeyUtils.cpp
Normal file
352
src/browser/PasskeyUtils.cpp
Normal file
|
@ -0,0 +1,352 @@
|
|||
/*
|
||||
* Copyright (C) 2024 KeePassXC Team <team@keepassxc.org>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include "PasskeyUtils.h"
|
||||
#include "BrowserMessageBuilder.h"
|
||||
#include "BrowserPasskeys.h"
|
||||
#include "core/Tools.h"
|
||||
#include "core/UrlTools.h"
|
||||
|
||||
#include <QList>
|
||||
#include <QUrl>
|
||||
|
||||
Q_GLOBAL_STATIC(PasskeyUtils, s_passkeyUtils);
|
||||
|
||||
PasskeyUtils* PasskeyUtils::instance()
|
||||
{
|
||||
return s_passkeyUtils;
|
||||
}
|
||||
|
||||
int PasskeyUtils::checkLimits(const QJsonObject& pkOptions) const
|
||||
{
|
||||
const auto challenge = pkOptions["challenge"].toString();
|
||||
if (challenge.isEmpty() || challenge.length() < 16) {
|
||||
return ERROR_PASSKEYS_INVALID_CHALLENGE;
|
||||
}
|
||||
|
||||
const auto userIdBase64 = pkOptions["user"]["id"].toString();
|
||||
const auto userId = browserMessageBuilder()->getArrayFromBase64(userIdBase64);
|
||||
if (userId.isEmpty() || (userId.length() < 1 || userId.length() > 64)) {
|
||||
return ERROR_PASSKEYS_INVALID_USER_ID;
|
||||
}
|
||||
|
||||
return PASSKEYS_SUCCESS;
|
||||
}
|
||||
|
||||
// Basic check for the object that it contains necessary variables in a correct form
|
||||
bool PasskeyUtils::checkCredentialCreationOptions(const QJsonObject& credentialCreationOptions) const
|
||||
{
|
||||
if (!credentialCreationOptions["attestation"].isString()
|
||||
|| credentialCreationOptions["attestation"].toString().isEmpty()
|
||||
|| !credentialCreationOptions["clientDataJSON"].isObject()
|
||||
|| credentialCreationOptions["clientDataJSON"].toObject().isEmpty()
|
||||
|| !credentialCreationOptions["rp"].isObject() || credentialCreationOptions["rp"].toObject().isEmpty()
|
||||
|| !credentialCreationOptions["user"].isObject() || credentialCreationOptions["user"].toObject().isEmpty()
|
||||
|| !credentialCreationOptions["residentKey"].isBool() || credentialCreationOptions["residentKey"].isUndefined()
|
||||
|| !credentialCreationOptions["userPresence"].isBool()
|
||||
|| credentialCreationOptions["userPresence"].isUndefined()
|
||||
|| !credentialCreationOptions["userVerification"].isBool()
|
||||
|| credentialCreationOptions["userVerification"].isUndefined()
|
||||
|| !credentialCreationOptions["credTypesAndPubKeyAlgs"].isArray()
|
||||
|| credentialCreationOptions["credTypesAndPubKeyAlgs"].toArray().isEmpty()
|
||||
|| !credentialCreationOptions["excludeCredentials"].isArray()
|
||||
|| credentialCreationOptions["excludeCredentials"].isUndefined()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Basic check for the object that it contains necessary variables in a correct form
|
||||
bool PasskeyUtils::checkCredentialAssertionOptions(const QJsonObject& assertionOptions) const
|
||||
{
|
||||
if (!assertionOptions["clientDataJson"].isObject() || assertionOptions["clientDataJson"].toObject().isEmpty()
|
||||
|| !assertionOptions["rpId"].isString() || assertionOptions["rpId"].toString().isEmpty()
|
||||
|| !assertionOptions["userPresence"].isBool() || assertionOptions["userPresence"].isUndefined()
|
||||
|| !assertionOptions["userVerification"].isBool() || assertionOptions["userVerification"].isUndefined()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
int PasskeyUtils::getEffectiveDomain(const QString& origin, QString* result) const
|
||||
{
|
||||
if (!result) {
|
||||
return ERROR_PASSKEYS_ORIGIN_NOT_ALLOWED;
|
||||
}
|
||||
|
||||
if (origin.isEmpty()) {
|
||||
return ERROR_PASSKEYS_ORIGIN_NOT_ALLOWED;
|
||||
}
|
||||
|
||||
const auto effectiveDomain = QUrl::fromUserInput(origin).host();
|
||||
if (!isDomain(effectiveDomain)) {
|
||||
return ERROR_PASSKEYS_DOMAIN_IS_NOT_VALID;
|
||||
}
|
||||
|
||||
*result = effectiveDomain;
|
||||
return PASSKEYS_SUCCESS;
|
||||
}
|
||||
|
||||
int PasskeyUtils::validateRpId(const QJsonValue& rpIdValue, const QString& effectiveDomain, QString* result) const
|
||||
{
|
||||
if (!result) {
|
||||
return ERROR_PASSKEYS_DOMAIN_RPID_MISMATCH;
|
||||
}
|
||||
|
||||
if (rpIdValue.isUndefined()) {
|
||||
return ERROR_PASSKEYS_DOMAIN_RPID_MISMATCH;
|
||||
}
|
||||
|
||||
if (effectiveDomain.isEmpty()) {
|
||||
return ERROR_PASSKEYS_ORIGIN_NOT_ALLOWED;
|
||||
}
|
||||
|
||||
const auto rpId = rpIdValue.toString();
|
||||
if (!isRegistrableDomainSuffix(rpId, effectiveDomain)) {
|
||||
return ERROR_PASSKEYS_DOMAIN_RPID_MISMATCH;
|
||||
}
|
||||
|
||||
if (rpId == effectiveDomain) {
|
||||
*result = effectiveDomain;
|
||||
return PASSKEYS_SUCCESS;
|
||||
}
|
||||
|
||||
*result = rpId;
|
||||
return PASSKEYS_SUCCESS;
|
||||
}
|
||||
|
||||
// https://www.w3.org/TR/2021/REC-webauthn-2-20210408/#dom-publickeycredentialcreationoptions-attestation
|
||||
QString PasskeyUtils::parseAttestation(const QString& attestation) const
|
||||
{
|
||||
return attestation == BrowserPasskeys::PASSKEYS_ATTESTATION_DIRECT ? BrowserPasskeys::PASSKEYS_ATTESTATION_DIRECT
|
||||
: BrowserPasskeys::PASSKEYS_ATTESTATION_NONE;
|
||||
}
|
||||
|
||||
QJsonArray PasskeyUtils::parseCredentialTypes(const QJsonArray& credentialTypes) const
|
||||
{
|
||||
QJsonArray credTypesAndPubKeyAlgs;
|
||||
|
||||
if (credentialTypes.isEmpty()) {
|
||||
// Set default values
|
||||
credTypesAndPubKeyAlgs.push_back(QJsonObject({
|
||||
{"type", BrowserPasskeys::PUBLIC_KEY},
|
||||
{"alg", WebAuthnAlgorithms::ES256},
|
||||
}));
|
||||
credTypesAndPubKeyAlgs.push_back(QJsonObject({
|
||||
{"type", BrowserPasskeys::PUBLIC_KEY},
|
||||
{"alg", WebAuthnAlgorithms::RS256},
|
||||
}));
|
||||
} else {
|
||||
for (const auto current : credentialTypes) {
|
||||
if (current["type"] != BrowserPasskeys::PUBLIC_KEY || current["alg"].isUndefined()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const auto currentAlg = current["alg"].toInt();
|
||||
if (currentAlg != WebAuthnAlgorithms::ES256 && currentAlg != WebAuthnAlgorithms::RS256
|
||||
&& currentAlg != WebAuthnAlgorithms::EDDSA) {
|
||||
continue;
|
||||
}
|
||||
|
||||
credTypesAndPubKeyAlgs.push_back(QJsonObject({
|
||||
{"type", current["type"]},
|
||||
{"alg", currentAlg},
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return credTypesAndPubKeyAlgs;
|
||||
}
|
||||
|
||||
bool PasskeyUtils::isAuthenticatorSelectionValid(const QJsonObject& authenticatorSelection) const
|
||||
{
|
||||
const auto authenticatorAttachment = authenticatorSelection["authenticatorAttachment"].toString();
|
||||
if (!authenticatorAttachment.isEmpty() && authenticatorAttachment != BrowserPasskeys::ATTACHMENT_PLATFORM
|
||||
&& authenticatorAttachment != BrowserPasskeys::ATTACHMENT_CROSS_PLATFORM) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto requireResidentKey = authenticatorSelection["requireResidentKey"].toBool();
|
||||
if (requireResidentKey && !BrowserPasskeys::SUPPORT_RESIDENT_KEYS) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto residentKey = authenticatorSelection["residentKey"].toString();
|
||||
if (residentKey == "required" && !BrowserPasskeys::SUPPORT_RESIDENT_KEYS) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (residentKey.isEmpty() && requireResidentKey && !BrowserPasskeys::SUPPORT_RESIDENT_KEYS) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto userVerification = authenticatorSelection["userVerification"].toBool();
|
||||
if (userVerification && !BrowserPasskeys::SUPPORT_USER_VERIFICATION) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool PasskeyUtils::isRegistrableDomainSuffix(const QString& hostSuffixString, const QString& originalHost) const
|
||||
{
|
||||
if (hostSuffixString.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isDomain(originalHost)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto hostSuffix = QUrl::fromUserInput(hostSuffixString).host();
|
||||
if (hostSuffix == originalHost) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!isDomain(hostSuffix)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto prefixedHostSuffix = QString(".%1").arg(hostSuffix);
|
||||
if (!originalHost.endsWith(prefixedHostSuffix)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hostSuffix == urlTools()->getTopLevelDomainFromUrl(hostSuffix)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto originalPublicSuffix = urlTools()->getTopLevelDomainFromUrl(originalHost);
|
||||
if (originalPublicSuffix.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (originalPublicSuffix.endsWith(prefixedHostSuffix)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!hostSuffix.endsWith(QString(".%1").arg(originalPublicSuffix))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool PasskeyUtils::isDomain(const QString& hostName) const
|
||||
{
|
||||
const auto domain = QUrl::fromUserInput(hostName).host();
|
||||
return !domain.isEmpty() && !domain.endsWith('.') && Tools::isAsciiString(domain)
|
||||
&& !urlTools()->domainHasIllegalCharacters(domain) && !urlTools()->isIpAddress(hostName);
|
||||
}
|
||||
|
||||
bool PasskeyUtils::isUserVerificationValid(const QString& userVerification) const
|
||||
{
|
||||
return QStringList({BrowserPasskeys::REQUIREMENT_PREFERRED,
|
||||
BrowserPasskeys::REQUIREMENT_REQUIRED,
|
||||
BrowserPasskeys::REQUIREMENT_DISCOURAGED})
|
||||
.contains(userVerification);
|
||||
}
|
||||
|
||||
bool PasskeyUtils::isOriginAllowedWithLocalhost(bool allowLocalhostWithPasskeys, const QString& origin) const
|
||||
{
|
||||
if (origin.startsWith("https://") || (allowLocalhostWithPasskeys && origin.startsWith("file://"))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!allowLocalhostWithPasskeys) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto host = QUrl::fromUserInput(origin).host();
|
||||
return host == "localhost" || host == "localhost." || host.endsWith(".localhost") || host.endsWith(".localhost.");
|
||||
}
|
||||
|
||||
bool PasskeyUtils::isResidentKeyRequired(const QJsonObject& authenticatorSelection) const
|
||||
{
|
||||
if (authenticatorSelection.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto residentKey = authenticatorSelection["residentKey"].toString();
|
||||
if (residentKey == BrowserPasskeys::REQUIREMENT_REQUIRED
|
||||
|| (BrowserPasskeys::SUPPORT_RESIDENT_KEYS && residentKey == BrowserPasskeys::REQUIREMENT_PREFERRED)) {
|
||||
return true;
|
||||
} else if (residentKey == BrowserPasskeys::REQUIREMENT_DISCOURAGED) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return authenticatorSelection["requireResidentKey"].toBool();
|
||||
}
|
||||
|
||||
bool PasskeyUtils::isUserVerificationRequired(const QJsonObject& authenticatorSelection) const
|
||||
{
|
||||
const auto userVerification = authenticatorSelection["userVerification"].toString();
|
||||
return userVerification == BrowserPasskeys::REQUIREMENT_REQUIRED
|
||||
|| (userVerification == BrowserPasskeys::REQUIREMENT_PREFERRED
|
||||
&& BrowserPasskeys::SUPPORT_USER_VERIFICATION);
|
||||
}
|
||||
|
||||
QByteArray PasskeyUtils::buildExtensionData(QJsonObject& extensionObject) const
|
||||
{
|
||||
// Only supports "credProps" and "uvm" for now
|
||||
const QStringList allowedKeys = {"credProps", "uvm"};
|
||||
|
||||
// Remove unsupported keys
|
||||
for (const auto& key : extensionObject.keys()) {
|
||||
if (!allowedKeys.contains(key)) {
|
||||
extensionObject.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
auto extensionData = m_browserCbor.cborEncodeExtensionData(extensionObject);
|
||||
if (!extensionData.isEmpty()) {
|
||||
return extensionData;
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
QJsonObject PasskeyUtils::buildClientDataJson(const QJsonObject& publicKey, const QString& origin, bool get) const
|
||||
{
|
||||
QJsonObject clientData;
|
||||
clientData["challenge"] = publicKey["challenge"];
|
||||
clientData["crossOrigin"] = false;
|
||||
clientData["origin"] = origin;
|
||||
clientData["type"] = get ? QString("webauthn.get") : QString("webauthn.create");
|
||||
|
||||
return clientData;
|
||||
}
|
||||
|
||||
QStringList PasskeyUtils::getAllowedCredentialsFromAssertionOptions(const QJsonObject& assertionOptions) const
|
||||
{
|
||||
QStringList allowedCredentials;
|
||||
for (const auto& credential : assertionOptions["allowCredentials"].toArray()) {
|
||||
const auto cred = credential.toObject();
|
||||
const auto id = cred["id"].toString();
|
||||
const auto transports = cred["transports"].toArray();
|
||||
const auto hasSupportedTransport =
|
||||
transports.isEmpty() || transports.contains(BrowserPasskeys::AUTHENTICATOR_TRANSPORT);
|
||||
|
||||
if (cred["type"].toString() == BrowserPasskeys::PUBLIC_KEY && hasSupportedTransport && !id.isEmpty()) {
|
||||
allowedCredentials << id;
|
||||
}
|
||||
}
|
||||
|
||||
return allowedCredentials;
|
||||
}
|
74
src/browser/PasskeyUtils.h
Normal file
74
src/browser/PasskeyUtils.h
Normal file
|
@ -0,0 +1,74 @@
|
|||
/*
|
||||
* Copyright (C) 2024 KeePassXC Team <team@keepassxc.org>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#ifndef PASSKEYUTILS_H
|
||||
#define PASSKEYUTILS_H
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QObject>
|
||||
#include <QStringList>
|
||||
|
||||
#include "BrowserCbor.h"
|
||||
|
||||
#define DEFAULT_TIMEOUT 300000
|
||||
#define DEFAULT_DISCOURAGED_TIMEOUT 120000
|
||||
#define PASSKEYS_SUCCESS 0
|
||||
|
||||
class PasskeyUtils : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit PasskeyUtils() = default;
|
||||
~PasskeyUtils() = default;
|
||||
static PasskeyUtils* instance();
|
||||
|
||||
int checkLimits(const QJsonObject& pkOptions) const;
|
||||
bool checkCredentialCreationOptions(const QJsonObject& credentialCreationOptions) const;
|
||||
bool checkCredentialAssertionOptions(const QJsonObject& assertionOptions) const;
|
||||
int getEffectiveDomain(const QString& origin, QString* result) const;
|
||||
int validateRpId(const QJsonValue& rpIdValue, const QString& effectiveDomain, QString* result) const;
|
||||
QString parseAttestation(const QString& attestation) const;
|
||||
QJsonArray parseCredentialTypes(const QJsonArray& credentialTypes) const;
|
||||
bool isAuthenticatorSelectionValid(const QJsonObject& authenticatorSelection) const;
|
||||
bool isUserVerificationValid(const QString& userVerification) const;
|
||||
bool isResidentKeyRequired(const QJsonObject& authenticatorSelection) const;
|
||||
bool isUserVerificationRequired(const QJsonObject& authenticatorSelection) const;
|
||||
bool isOriginAllowedWithLocalhost(bool allowLocalhostWithPasskeys, const QString& origin) const;
|
||||
QByteArray buildExtensionData(QJsonObject& extensionObject) const;
|
||||
QJsonObject buildClientDataJson(const QJsonObject& publicKey, const QString& origin, bool get) const;
|
||||
QStringList getAllowedCredentialsFromAssertionOptions(const QJsonObject& assertionOptions) const;
|
||||
|
||||
private:
|
||||
Q_DISABLE_COPY(PasskeyUtils);
|
||||
|
||||
bool isRegistrableDomainSuffix(const QString& hostSuffixString, const QString& originalHost) const;
|
||||
bool isDomain(const QString& hostName) const;
|
||||
|
||||
friend class TestPasskeys;
|
||||
|
||||
private:
|
||||
BrowserCbor m_browserCbor;
|
||||
};
|
||||
|
||||
static inline PasskeyUtils* passkeyUtils()
|
||||
{
|
||||
return PasskeyUtils::instance();
|
||||
}
|
||||
|
||||
#endif // PASSKEYUTILS_H
|
Loading…
Add table
Add a link
Reference in a new issue