#include #include #include #include #include #include #include #include #include #include #include #include #include // The modern LocalSend protocol server (the Rust implementation) requires // mutual TLS: it only accepts a sender that presents a valid client // certificate during the TLS handshake. These tests spin up a QSslServer with // mandatory client-certificate verification and assert that LocalSend's // HttpClient can complete the handshake *only when* its SSL configuration // carries the device certificate (regression test for // "tlsv13 alert certificate required" when sending files). class TestHttpClientMtls : public QObject { Q_OBJECT private slots: void initTestCase(); void cleanupTestCase(); void testSslConfigurationCarriesDeviceIdentity(); void testGetInfoSucceedsWithClientCertificate(); void testGetInfoFailsWithoutClientCertificate(); private: // Starts a QHttpServer over TLS that REQUIRES a client certificate. // The trust store is seeded with our device certificate, so only a client // presenting that certificate passes. Returns the listening port. quint16 startMtlsServer(QHttpServer& http, QSslServer& ssl, const LocalSend::SecurityContext& sec); QString m_configDir; LocalSend::SecurityContext* m_sec = nullptr; }; void TestHttpClientMtls::initTestCase() { // Isolated storage in the (writable) build directory so the test never // touches the real app certificate or depends on the user's HOME. m_configDir = QDir::currentPath() + QStringLiteral("/.mtls-test-cfg"); QDir().mkpath(m_configDir); qputenv("XDG_CONFIG_HOME", m_configDir.toUtf8()); m_sec = new LocalSend::SecurityContext(this); m_sec->initialize(); } void TestHttpClientMtls::cleanupTestCase() { delete m_sec; m_sec = nullptr; QDir(m_configDir).removeRecursively(); } void TestHttpClientMtls::testSslConfigurationCarriesDeviceIdentity() { QVERIFY(m_sec); QSslConfiguration config = m_sec->sslConfiguration(); QVERIFY(!config.isNull()); QVERIFY(!config.localCertificate().isNull()); QVERIFY(!m_sec->privateKey().isNull()); // The config doubles as the client identity: it must carry the device // certificate that the handshake presents to the receiver. QCOMPARE(config.localCertificate().digest(QCryptographicHash::Sha256), m_sec->certificate().digest(QCryptographicHash::Sha256)); } void TestHttpClientMtls::testGetInfoSucceedsWithClientCertificate() { QVERIFY(m_sec); QVERIFY(!m_sec->sslConfiguration().isNull()); QHttpServer http; QSslServer ssl; quint16 port = startMtlsServer(http, ssl, *m_sec); QVERIFY(port != 0); LocalSend::HttpClient client; // The app wires the same security context into the client (AppController). client.setSslConfiguration(m_sec->sslConfiguration()); LocalSend::Device device(QStringLiteral("127.0.0.1"), port); device.protocol = LocalSend::ProtocolType::Https; bool gotInfo = false; bool gotError = false; QObject::connect(&client, &LocalSend::HttpClient::infoReceived, [&](const LocalSend::InfoDto&) { gotInfo = true; }); QObject::connect(&client, &LocalSend::HttpClient::infoError, [&](const QString&) { gotError = true; }); client.getInfo(device); QTRY_VERIFY_WITH_TIMEOUT(gotInfo || gotError, 5000); QVERIFY(!gotError); QVERIFY(gotInfo); } void TestHttpClientMtls::testGetInfoFailsWithoutClientCertificate() { QVERIFY(m_sec); QHttpServer http; QSslServer ssl; quint16 port = startMtlsServer(http, ssl, *m_sec); QVERIFY(port != 0); // NO client SSL configuration: like the app before the fix, the client // does not present a certificate and the handshake must be rejected. LocalSend::HttpClient client; LocalSend::Device device(QStringLiteral("127.0.0.1"), port); device.protocol = LocalSend::ProtocolType::Https; bool gotInfo = false; bool gotError = false; QObject::connect(&client, &LocalSend::HttpClient::infoReceived, [&](const LocalSend::InfoDto&) { gotInfo = true; }); QObject::connect(&client, &LocalSend::HttpClient::infoError, [&](const QString&) { gotError = true; }); client.getInfo(device); QTRY_VERIFY_WITH_TIMEOUT(gotInfo || gotError, 5000); QVERIFY(!gotInfo); QVERIFY(gotError); } quint16 TestHttpClientMtls::startMtlsServer(QHttpServer& http, QSslServer& ssl, const LocalSend::SecurityContext& sec) { http.route(QString::fromLatin1(LocalSend::ApiRoute::INFO), QHttpServerRequest::Method::Get, [](const QHttpServerRequest&) { QJsonObject o; o[QStringLiteral("alias")] = QStringLiteral("test-receiver"); o[QStringLiteral("version")] = QStringLiteral("1.0"); o[QStringLiteral("fingerprint")] = QStringLiteral("AABB"); return QHttpServerResponse(QJsonDocument(o).toJson(QJsonDocument::Compact), QHttpServerResponse::StatusCode::Ok); }); QSslConfiguration serverSsl; serverSsl.setLocalCertificate(sec.certificate()); serverSsl.setPrivateKey(sec.privateKey()); serverSsl.setPeerVerifyMode(QSslSocket::VerifyPeer); // mandatory client cert serverSsl.setCaCertificates({ sec.certificate() }); // trust our device cert ssl.setSslConfiguration(serverSsl); if (!ssl.listen(QHostAddress::LocalHost, 0)) { qWarning() << "Failed to listen for MTLS test server"; return 0; } http.bind(&ssl); return ssl.serverPort(); } QTEST_MAIN(TestHttpClientMtls) #include "TestHttpClientMtls.moc"