Passkeys improvements (#10318)

Refactors the Passkey implementation to include more checks and a structure that is more aligned with the official specification.
Notable changes:
- _BrowserService_ no longer does the checks by itself. A new class _BrowserPasskeysClient_ constructs the relevant objects, acting as a client. _BrowserService_ only acts as a bridge between the client and _BrowserPasskeys_ (authenticator) and calls the relevant popups for user interaction.
- A new helper class _PasskeyUtils_ includes the actual checks and parses the objects.
- _BrowserPasskeys_ is pretty much intact, but some functions have been moved to PasskeyUtils.
- Fixes Ed25519 encoding in _BrowserCBOR_.
- Adds new error messages.
- User confirmation for Passkey retrieval is also asked even if `discouraged` is used. This goes against the specification, but currently there's no other way to verify the user.
- `cross-platform` is also accepted for compatibility. This could be removed if there's a potential issue with it.
- Extension data is now handled correctly during Authentication.
- Allowed and excluded credentials are now handled correctly.
- `KPEX_PASSKEY_GENERATED_USER_ID` is renamed to `KPEX_PASSKEY_CREDENTIAL_ID`
- Adds a new option "Allow localhost with Passkeys" to Browser Integration -> Advanced tab. By default it's not allowed to access HTTP sites, but `http://localhost` can be allowed for debugging and testing purposes for local servers.
- Add tag `Passkey` to a Passkey entry, or an entry with an imported Passkey.

Fixes #10287.
This commit is contained in:
Sami Vänttinen 2024-03-06 14:42:01 +02:00 committed by GitHub
parent dff2f186ce
commit ac2b445db6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 1248 additions and 269 deletions

View file

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

View file

@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
* Copyright (C) 2024 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -45,5 +45,14 @@ private slots:
void testSetFlags();
void testEntry();
void testIsDomain();
void testRegistrableDomainSuffix();
void testRpIdValidation();
void testParseAttestation();
void testParseCredentialTypes();
void testIsAuthenticatorSelectionValid();
void testIsResidentKeyRequired();
void testIsUserVerificationRequired();
void testAllowLocalhostWithPasskeys();
};
#endif // KEEPASSXC_TESTPASSKEYS_H

View file

@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
* Copyright (C) 2024 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -68,6 +68,14 @@ void TestTools::testIsBase64()
QVERIFY(!Tools::isBase64(QByteArray("123")));
}
void TestTools::testIsAsciiString()
{
QVERIFY(Tools::isAsciiString("abcd9876DEFGhijkMNO"));
QVERIFY(Tools::isAsciiString("-!&5a?`~"));
QVERIFY(!Tools::isAsciiString("Štest"));
QVERIFY(!Tools::isAsciiString("Ãß"));
}
void TestTools::testEnvSubstitute()
{
QProcessEnvironment environment;

View file

@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
* Copyright (C) 2024 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -27,6 +27,7 @@ private slots:
void testHumanReadableFileSize();
void testIsHex();
void testIsBase64();
void testIsAsciiString();
void testEnvSubstitute();
void testValidUuid();
void testBackupFilePatternSubstitution_data();

View file

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

View file

@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
* Copyright (C) 2024 KeePassXC Team <team@keepassxc.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -34,6 +34,7 @@ private slots:
void testIsIpAddress();
void testIsUrlIdentical();
void testIsUrlValid();
void testDomainHasIllegalCharacters();
private:
QPointer<UrlTools> m_urlTools;