From 21f22912e9ee4cf061d4a91495b7cc193c628fb5 Mon Sep 17 00:00:00 2001 From: vibesoftwarecoder Date: Fri, 23 Oct 2026 10:44:32 -0500 Subject: [PATCH] Add the client core for USB forwarding (Windows only) Adds the pieces a client needs to ask an ApolloVibe host whether USB forwarding is available and to carry a device over a TLS tunnel: the capability check on the paired HTTPS port, a tunnel that pins the paired certificate and presents the client certificate, and a Windows backend for usbipd-win. usbipd is found only through the Windows service's registered path or Program Files, never PATH or the working directory. The file must be named usbipd.exe, must not be reachable by a link, and must sit in a location ordinary users cannot modify, checked with a real access check against the user's own token. Elevated commands are built only from a validated bus id or GUID. There is no settings UI and no session wiring yet, so nothing changes for a user. Linux and macOS build to a stub that reports "unsupported". Tests use loopback only. Co-Authored-By: Claude Sonnet 5.5 --- app/app.pro | 12 +- app/backend/nvhttp.cpp | 88 ++- app/backend/nvhttp.h | 12 +- app/backend/pairedcertificate.h | 56 ++ app/backend/usbforwardingbackend.cpp | 260 +++++++++ app/backend/usbforwardingbackend.h | 82 +++ app/backend/usbforwardingcapability.h | 59 ++ app/backend/usbforwardingenvironment.cpp | 545 ++++++++++++++++++ app/backend/usbforwardingenvironment.h | 101 ++++ app/backend/usbforwardingtunnel.cpp | 473 +++++++++++++++ app/backend/usbforwardingtunnel.h | 88 +++ tests/run_usb_forwarding_tests.ps1 | 91 +++ tests/usb_forwarding_backend_list/main.cpp | 169 ++++++ .../usb_forwarding_backend_list.pro | 14 + tests/usb_forwarding_capability/main.cpp | 130 +++++ .../usb_forwarding_capability.pro | 8 + tests/usb_forwarding_common/faketlsserver.h | 114 ++++ tests/usb_forwarding_environment/main.cpp | 344 +++++++++++ .../usb_forwarding_environment.pro | 9 + tests/usb_forwarding_nvhttp/main.cpp | 237 ++++++++ tests/usb_forwarding_nvhttp/stubs/Limelight.h | 18 + .../stubs/identitymanager.h | 30 + .../usb_forwarding_nvhttp/stubs/nvcomputer.h | 14 + .../usb_forwarding_nvhttp.pro | 25 + tests/usb_forwarding_tunnel/main.cpp | 470 +++++++++++++++ .../usb_forwarding_tunnel.pro | 12 + .../usb_forwarding_tunnel_clientcert/main.cpp | 61 ++ .../usb_forwarding_tunnel_clientcert.pro | 12 + 28 files changed, 3530 insertions(+), 4 deletions(-) create mode 100644 app/backend/pairedcertificate.h create mode 100644 app/backend/usbforwardingbackend.cpp create mode 100644 app/backend/usbforwardingbackend.h create mode 100644 app/backend/usbforwardingcapability.h create mode 100644 app/backend/usbforwardingenvironment.cpp create mode 100644 app/backend/usbforwardingenvironment.h create mode 100644 app/backend/usbforwardingtunnel.cpp create mode 100644 app/backend/usbforwardingtunnel.h create mode 100644 tests/run_usb_forwarding_tests.ps1 create mode 100644 tests/usb_forwarding_backend_list/main.cpp create mode 100644 tests/usb_forwarding_backend_list/usb_forwarding_backend_list.pro create mode 100644 tests/usb_forwarding_capability/main.cpp create mode 100644 tests/usb_forwarding_capability/usb_forwarding_capability.pro create mode 100644 tests/usb_forwarding_common/faketlsserver.h create mode 100644 tests/usb_forwarding_environment/main.cpp create mode 100644 tests/usb_forwarding_environment/usb_forwarding_environment.pro create mode 100644 tests/usb_forwarding_nvhttp/main.cpp create mode 100644 tests/usb_forwarding_nvhttp/stubs/Limelight.h create mode 100644 tests/usb_forwarding_nvhttp/stubs/identitymanager.h create mode 100644 tests/usb_forwarding_nvhttp/stubs/nvcomputer.h create mode 100644 tests/usb_forwarding_nvhttp/usb_forwarding_nvhttp.pro create mode 100644 tests/usb_forwarding_tunnel/main.cpp create mode 100644 tests/usb_forwarding_tunnel/usb_forwarding_tunnel.pro create mode 100644 tests/usb_forwarding_tunnel_clientcert/main.cpp create mode 100644 tests/usb_forwarding_tunnel_clientcert/usb_forwarding_tunnel_clientcert.pro diff --git a/app/app.pro b/app/app.pro index 56e6d6cb79..a17ba9e12e 100644 --- a/app/app.pro +++ b/app/app.pro @@ -50,7 +50,7 @@ win32 { } INCLUDEPATH += $$PWD/../libs/windows/include - LIBS += ws2_32.lib winmm.lib dxva2.lib ole32.lib gdi32.lib user32.lib d3d9.lib dwmapi.lib dbghelp.lib + LIBS += ws2_32.lib winmm.lib dxva2.lib ole32.lib gdi32.lib user32.lib d3d9.lib dwmapi.lib dbghelp.lib advapi32.lib shell32.lib } macx:!disable-prebuilts { !exists($$PWD/../libs/mac) { @@ -206,6 +206,9 @@ SOURCES += \ gui/sdlgamepadkeynavigation.cpp \ streaming/video/overlaymanager.cpp \ backend/systemproperties.cpp \ + backend/usbforwardingtunnel.cpp \ + backend/usbforwardingenvironment.cpp \ + backend/usbforwardingbackend.cpp \ wm.cpp HEADERS += \ @@ -246,7 +249,12 @@ HEADERS += \ settings/mappingmanager.h \ gui/sdlgamepadkeynavigation.h \ streaming/video/overlaymanager.h \ - backend/systemproperties.h + backend/systemproperties.h \ + backend/usbforwardingtunnel.h \ + backend/usbforwardingcapability.h \ + backend/pairedcertificate.h \ + backend/usbforwardingenvironment.h \ + backend/usbforwardingbackend.h # Platform-specific renderers and decoders ffmpeg { diff --git a/app/backend/nvhttp.cpp b/app/backend/nvhttp.cpp index 13ec4c4f47..d8fc7ad075 100644 --- a/app/backend/nvhttp.cpp +++ b/app/backend/nvhttp.cpp @@ -1,4 +1,5 @@ #include "nvcomputer.h" +#include "pairedcertificate.h" #include #include @@ -11,6 +12,7 @@ #include #include #include +#include #define FAST_FAIL_TIMEOUT_MS 2000 #define REQUEST_TIMEOUT_MS 5000 @@ -454,6 +456,35 @@ void NvHTTP::handleSslErrors(QNetworkReply* reply, const QList& error } } +UsbForwarding::Capability NvHTTP::getUsbForwardingCapability() +{ + // Derived from qiin2333/moonlight-qt (GPL-3.0). + if (m_ServerCert.isNull() || m_BaseUrlHttps.port(0) == 0) { + throw GfeHttpResponseException(401, "USB forwarding requires a paired host"); + } + // The bounded openConnection() pins the exact paired certificate, refuses + // redirects and caps the response size. The token in the reply must never + // reach a host that is not the paired one. + std::unique_ptr reply(openConnection(m_BaseUrlHttps, + "api/v1/usb-forwarding", nullptr, REQUEST_TIMEOUT_MS, NvLogLevel::NVLL_NONE, + UsbForwarding::kMaxHandshakeBytes)); + // A normally trusted certificate may not emit sslErrors at all. Require + // the paired leaf certificate even on that path before accepting credentials. + if (!PairedCertificate::matches(m_ServerCert, reply->sslConfiguration().peerCertificate())) { + throw QtNetworkReplyException(QNetworkReply::SslHandshakeFailedError, + "USB forwarding host certificate mismatch"); + } + if (reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() != 200) { + throw GfeHttpResponseException(400, "USB capability request failed"); + } + // Never log the body: it carries the tunnel token. + const auto capability = UsbForwarding::Capability::parse(reply->readAll()); + if (!capability) { + throw GfeHttpResponseException(400, "Invalid USB capability response"); + } + return *capability; +} + QString NvHTTP::openConnectionToString(QUrl baseUrl, QString command, @@ -483,11 +514,18 @@ NvHTTP::openConnection(QUrl baseUrl, QString command, QString arguments, int timeoutMs, - NvLogLevel logLevel) + NvLogLevel logLevel, + qint64 maxResponseBytes) { // Port must be set Q_ASSERT(baseUrl.port(0) != 0); + // A bounded request (maxResponseBytes > 0) carries credentials back to us, + // so it gets extra protection: no redirects, a response size cap and a + // check that the peer is exactly the paired certificate. Unbounded + // requests behave exactly as before. + const bool bounded = maxResponseBytes > 0; + // Build a URL for the request QUrl url(baseUrl); url.setPath("/" + command); @@ -499,6 +537,12 @@ NvHTTP::openConnection(QUrl baseUrl, QNetworkRequest request(url); + if (bounded) { + // Never forward the request to another URL. + request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, + QNetworkRequest::ManualRedirectPolicy); + } + // Add our client certificate request.setSslConfiguration(IdentityManager::get()->getSslConfig()); @@ -518,9 +562,26 @@ NvHTTP::openConnection(QUrl baseUrl, #endif auto sslErrorsConnection = connect(m_Nam, &QNetworkAccessManager::sslErrors, this, &NvHTTP::handleSslErrors); + QMetaObject::Connection pinnedConnection; + if (bounded) { + // A normally trusted peer may produce no sslErrors, so also check the + // pin before any HTTP data is sent. + pinnedConnection = PairedCertificate::enforce(m_Nam, this, m_ServerCert); + } QNetworkReply* reply = m_Nam->get(request); QEventLoop loop; + bool oversized = false; + if (bounded) { + reply->setReadBufferSize(maxResponseBytes + 1); + connect(reply, &QNetworkReply::readyRead, &loop, [&] { + if (reply->bytesAvailable() > maxResponseBytes) { + oversized = true; + reply->abort(); + } + }); + } + // Connect the quit signals connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); connect(QCoreApplication::instance(), &QCoreApplication::aboutToQuit, &loop, &QEventLoop::quit); @@ -568,6 +629,31 @@ NvHTTP::openConnection(QUrl baseUrl, #endif disconnect(sslErrorsConnection); + if (bounded) { + disconnect(pinnedConnection); + + // The pin check comes first: a pin mismatch aborts the reply, which + // would otherwise be reported as a timeout. + // A request that never reached a TLS peer (refused, timed out) keeps + // its own error below. + const QSslCertificate peerCertificate = reply->sslConfiguration().peerCertificate(); + if ((reply->error() == QNetworkReply::NoError || !peerCertificate.isNull()) && + !PairedCertificate::matches(m_ServerCert, peerCertificate)) { + delete reply; + throw QtNetworkReplyException(QNetworkReply::SslHandshakeFailedError, + "Server certificate mismatch"); + } + if (oversized || reply->bytesAvailable() > maxResponseBytes) { + delete reply; + throw GfeHttpResponseException(400, "Host response too large"); + } + const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + if (status >= 300 && status < 400) { + delete reply; + throw GfeHttpResponseException(status, "Host redirects are not allowed"); + } + } + // Handle error if (reply->error() != QNetworkReply::NoError) { diff --git a/app/backend/nvhttp.h b/app/backend/nvhttp.h index ea70780104..5a50a1885c 100644 --- a/app/backend/nvhttp.h +++ b/app/backend/nvhttp.h @@ -3,6 +3,7 @@ #include "identitymanager.h" #include "nvapp.h" #include "nvaddress.h" +#include "usbforwardingcapability.h" #include @@ -141,6 +142,14 @@ class NvHTTP : public QObject int timeoutMs, NvLogLevel logLevel = NvLogLevel::NVLL_VERBOSE); + // Asks the paired host whether USB forwarding is available and, if so, + // for the tunnel port and token (GET /api/v1/usb-forwarding). Pins the + // exact paired certificate, allows no redirects and no more than 4096 + // response bytes. Throws GfeHttpResponseException or + // QtNetworkReplyException on any failure, including an unpaired host. + UsbForwarding::Capability + getUsbForwardingCapability(); + void setServerCert(QSslCertificate serverCert); void setAddress(NvAddress address); void setHttpsPort(uint16_t port); @@ -193,7 +202,8 @@ class NvHTTP : public QObject QString command, QString arguments, int timeoutMs, - NvLogLevel logLevel); + NvLogLevel logLevel, + qint64 maxResponseBytes = 0); NvAddress m_Address; QNetworkAccessManager* m_Nam; diff --git a/app/backend/pairedcertificate.h b/app/backend/pairedcertificate.h new file mode 100644 index 0000000000..20456205b8 --- /dev/null +++ b/app/backend/pairedcertificate.h @@ -0,0 +1,56 @@ +#pragma once + +// Derived from qiin2333/moonlight-qt (GPL-3.0). + +#include +#include +#include +#include +#include +#include + +namespace PairedCertificate { + +inline bool matches(const QSslCertificate& pinned, const QSslCertificate& peer) +{ + return !pinned.isNull() && !peer.isNull() && pinned == peer; +} + +inline bool canIgnoreErrors(const QSslCertificate& pinned, const QList& errors) +{ + if (pinned.isNull() || errors.isEmpty()) { + return false; + } + for (const QSslError& error : errors) { + if (!matches(pinned, error.certificate())) { + return false; + } + } + return true; +} + +// A normally trusted peer may produce no sslErrors. Check the pin before +// HTTP data is sent as well. The manager must be used exclusively under this +// policy, with automatic redirects disabled. Change identities only between +// requests, disconnecting the old enforcement connection first. +inline QMetaObject::Connection enforce(QNetworkAccessManager* manager, QObject* context, + const QSslCertificate& pinned) +{ + // Reused connections do not emit encrypted(). Discard connections created + // before adopting this policy or under a previous paired identity. + const char* identityProperty = "_moonlightPairedCertificate"; + const QByteArray identity = pinned.toDer(); + const QVariant previous = manager->property(identityProperty); + if (!previous.isValid() || previous.toByteArray() != identity) { + manager->clearConnectionCache(); + manager->setProperty(identityProperty, identity); + } + return QObject::connect(manager, &QNetworkAccessManager::encrypted, context, + [pinned](QNetworkReply* reply) { + if (!matches(pinned, reply->sslConfiguration().peerCertificate())) { + reply->abort(); + } + }); +} + +} // namespace PairedCertificate diff --git a/app/backend/usbforwardingbackend.cpp b/app/backend/usbforwardingbackend.cpp new file mode 100644 index 0000000000..54f20f219d --- /dev/null +++ b/app/backend/usbforwardingbackend.cpp @@ -0,0 +1,260 @@ +// Derived from qiin2333/moonlight-qt (GPL-3.0). + +#include "usbforwardingbackend.h" +#include "usbforwardingcapability.h" +#include "usbforwardingenvironment.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef Q_OS_WIN +#include +#include +#endif + +namespace { + +// Parses "USB\VID_054C&PID_0CE6\..." into "054c:0ce6". +QString vidPidFromInstanceId(const QString &instanceId) +{ + static const QRegularExpression vidRe(QStringLiteral("VID_([0-9A-Fa-f]{4})")); + static const QRegularExpression pidRe(QStringLiteral("PID_([0-9A-Fa-f]{4})")); + const QRegularExpressionMatch vidMatch = vidRe.match(instanceId); + const QRegularExpressionMatch pidMatch = pidRe.match(instanceId); + if (!vidMatch.hasMatch() || !pidMatch.hasMatch()) { + return QString(); + } + return vidMatch.captured(1).toLower() + QLatin1Char(':') + pidMatch.captured(1).toLower(); +} + +} // namespace + +UsbForwardingBackend::UsbForwardingBackend(QObject *parent) : QObject(parent) {} + +UsbForwardingBackend *UsbForwardingBackend::get() +{ + static UsbForwardingBackend backend; + return &backend; +} + +bool UsbForwardingBackend::isShareableBusId(const QString &busId) +{ + static const QRegularExpression realRe(QStringLiteral("\\A[1-9][0-9]*-[1-9][0-9]*\\z")); + // Check the shared charset and length rule first, then the stricter shape. + return UsbForwarding::isValidBusId(busId.toUtf8()) && realRe.match(busId).hasMatch(); +} + +bool UsbForwardingBackend::isValidPersistedGuid(const QString &guid) +{ + static const QRegularExpression guidRe(QStringLiteral( + "\\A[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\\z")); + return guidRe.match(guid).hasMatch(); +} + +QString UsbForwardingBackend::bindArguments(const QString &busId) +{ + if (!isShareableBusId(busId)) { + return QString(); + } + return QStringLiteral("bind --busid ") + busId; +} + +QString UsbForwardingBackend::unbindArguments(const QString &busId, const QString &persistedGuid) +{ + if (isShareableBusId(busId)) { + return QStringLiteral("unbind --busid ") + busId; + } + if (isValidPersistedGuid(persistedGuid)) { + return QStringLiteral("unbind --guid ") + persistedGuid; + } + return QString(); +} + +QVariantList UsbForwardingBackend::parseUsbipdState(const QByteArray &stateJson, QString *error) +{ + if (error != nullptr) { + error->clear(); + } + QJsonParseError parseError; + const QJsonDocument doc = QJsonDocument::fromJson(stateJson, &parseError); + if (parseError.error != QJsonParseError::NoError || !doc.isObject() || + !doc.object().value(QLatin1String("Devices")).isArray()) { + if (error != nullptr) { + *error = tr("Could not parse usbipd state output."); + } + return {}; + } + + QVariantList devices; + const QJsonArray array = doc.object().value(QLatin1String("Devices")).toArray(); + for (const QJsonValue &value : array) { + const QJsonObject o = value.toObject(); + const QString busId = o.value(QLatin1String("BusId")).toString(); + const QString clientIp = o.value(QLatin1String("ClientIPAddress")).toString(); + const QString description = o.value(QLatin1String("Description")).toString(); + const QString instanceId = o.value(QLatin1String("InstanceId")).toString(); + const bool isForced = o.value(QLatin1String("IsForced")).toBool(); + const QString persistedGuid = o.value(QLatin1String("PersistedGuid")).toString(); + + const bool isBound = !persistedGuid.isEmpty(); + const bool isConnected = !busId.isEmpty(); + const bool isAttached = !clientIp.isEmpty(); + const bool supported = isConnected && isShareableBusId(busId); + + QVariantMap device; + device.insert(QStringLiteral("busId"), busId); + device.insert(QStringLiteral("description"), description); + device.insert(QStringLiteral("instanceId"), instanceId); + device.insert(QStringLiteral("vidPid"), vidPidFromInstanceId(instanceId)); + device.insert(QStringLiteral("isBound"), isBound); + device.insert(QStringLiteral("isConnected"), isConnected); + device.insert(QStringLiteral("isAttached"), isAttached); + device.insert(QStringLiteral("isSupported"), supported); + device.insert(QStringLiteral("isForced"), isForced); + device.insert(QStringLiteral("persistedGuid"), persistedGuid); + devices.append(device); + } + return devices; +} + +void UsbForwardingBackend::setBusy(bool busy) +{ + if (m_Busy == busy) { + return; + } + m_Busy = busy; + emit busyChanged(); +} + +void UsbForwardingBackend::setError(const QString &error) +{ + if (m_Error == error) { + return; + } + m_Error = error; + emit errorChanged(); +} + +void UsbForwardingBackend::refresh() +{ + if (m_Busy) { + return; + } +#ifdef Q_OS_WIN + const QString exe = UsbForwardingEnvironment::locateUsbipd(); + if (exe.isEmpty()) { + m_Devices.clear(); + emit devicesChanged(); + setError(tr("usbipd-win is not installed.")); + return; + } + setBusy(true); + setError(QString()); + + QProcess *probe = new QProcess(this); + /* FailedToStart emits errorOccurred but never finished; without this the + * busy flag would stick and the device list would go stale. */ + connect(probe, &QProcess::errorOccurred, this, + [this, probe](QProcess::ProcessError processError) { + if (processError != QProcess::FailedToStart) { + return; + } + probe->deleteLater(); + setBusy(false); + m_Devices.clear(); + emit devicesChanged(); + setError(tr("usbipd could not be started. Requires usbipd-win 2.2.0+.")); + }); + connect(probe, &QProcess::finished, this, [this, probe](int exitCode) { + probe->deleteLater(); + setBusy(false); + + if (exitCode != 0) { + const QString detail = + QString::fromLocal8Bit(probe->readAllStandardError()).simplified(); + m_Devices.clear(); + emit devicesChanged(); + setError( + detail.isEmpty() + ? tr("usbipd state failed (exit %1). Requires usbipd-win 2.2.0+.").arg(exitCode) + : detail); + return; + } + + QString parseError; + m_Devices = parseUsbipdState(probe->readAllStandardOutput(), &parseError); + emit devicesChanged(); + if (!parseError.isEmpty()) { + setError(parseError); + } + }); + QTimer::singleShot(8000, probe, &QProcess::kill); + probe->setWorkingDirectory(QFileInfo(exe).absolutePath()); + probe->start(exe, { QStringLiteral("state") }); +#else + m_Devices.clear(); + emit devicesChanged(); + setError(tr("USB forwarding is not supported on this platform.")); +#endif +} + +void UsbForwardingBackend::runElevated(const QString &arguments, const QString &successMessage) +{ +#ifdef Q_OS_WIN + const QString exe = UsbForwardingEnvironment::locateUsbipd(); + if (exe.isEmpty()) { + emit operationFinished(false, tr("usbipd-win is not installed.")); + return; + } + // "exe" is the validated absolute path; "arguments" came from + // bindArguments()/unbindArguments(). Nothing else reaches the UAC prompt. + const std::wstring wideExe = QDir::toNativeSeparators(exe).toStdWString(); + const std::wstring wideArgs = arguments.toStdWString(); + const std::wstring wideDir = QDir::toNativeSeparators(QFileInfo(exe).absolutePath()).toStdWString(); + const HINSTANCE result = ShellExecuteW(nullptr, L"runas", wideExe.c_str(), wideArgs.c_str(), + wideDir.c_str(), SW_HIDE); + const bool ok = reinterpret_cast(result) > 32; + if (ok) { + emit operationFinished(true, successMessage); + QTimer::singleShot(1500, this, [this] { refresh(); }); + } else { + emit operationFinished(false, tr("The elevation was cancelled or failed.")); + } +#else + Q_UNUSED(arguments); + Q_UNUSED(successMessage); + emit operationFinished(false, tr("USB forwarding is not supported on this platform.")); +#endif +} + +void UsbForwardingBackend::bind(const QString &busId) +{ + if (busId.isEmpty()) { + return; + } + const QString arguments = bindArguments(busId); + if (arguments.isEmpty()) { + emit operationFinished(false, tr("This USB device cannot be shared.")); + return; + } + runElevated(arguments, tr("Requested sharing. Refreshing device list...")); +} + +void UsbForwardingBackend::unbind(const QString &busId, const QString &persistedGuid) +{ + if (busId.isEmpty() && persistedGuid.isEmpty()) { + return; + } + const QString arguments = unbindArguments(busId, persistedGuid); + if (arguments.isEmpty()) { + emit operationFinished(false, tr("This USB device cannot be unshared.")); + return; + } + runElevated(arguments, tr("Requested stop sharing. Refreshing device list...")); +} diff --git a/app/backend/usbforwardingbackend.h b/app/backend/usbforwardingbackend.h new file mode 100644 index 0000000000..11b690b64d --- /dev/null +++ b/app/backend/usbforwardingbackend.h @@ -0,0 +1,82 @@ +#pragma once + +// Derived from qiin2333/moonlight-qt (GPL-3.0). + +#include +#include +#include + +// USB device sharing orchestration (Windows only; usbipd-win does the +// sharing). Lists devices from `usbipd state` JSON, and binds or unbinds +// them with an elevated usbipd.exe (UAC prompt). Bound state lives in +// usbipd's own registry data (PersistedGuid). +// +// SECURITY: the executable is always the validated absolute path from +// UsbForwardingEnvironment::locateUsbipd(). Elevated command lines are built +// only from a validated bus id or GUID, in a fixed form. +// +// Division of labour: UsbForwardingEnvironment checks the environment +// (version, service state); this class handles the devices. +// +// Each device in the list is a QVariantMap with these keys: +// busId "1-2", "IncompatibleHub", or "" (not connected) +// description device description +// instanceId Windows instance ID (USB\VID_XXXX&PID_YYYY\...) +// vidPid "xxxx:yyyy" +// isBound bool, PersistedGuid is not empty +// isConnected bool, present in the current listing +// isAttached bool, in use by a client right now +// isSupported bool, connected and the bus id is a real, shareable one +// isForced bool +// persistedGuid GUID of the persisted share, or "" +class UsbForwardingBackend : public QObject +{ + Q_OBJECT + Q_PROPERTY(QVariantList devices READ devices NOTIFY devicesChanged) + Q_PROPERTY(bool busy READ busy NOTIFY busyChanged) + Q_PROPERTY(QString error READ error NOTIFY errorChanged) + +public: + static UsbForwardingBackend* get(); + + // Parses the output of `usbipd state` into the device list above. On + // failure sets *error and returns an empty list. Static and pure so it + // can be unit tested without a process. + static QVariantList parseUsbipdState(const QByteArray &stateJson, QString *error); + + // "1-2" style bus ids only: digits-digit, no leading zero. Rejects + // "IncompatibleHub", the empty string, and anything with other characters. + static bool isShareableBusId(const QString &busId); + // 8-4-4-4-12 hex GUID without braces, as usbipd prints PersistedGuid. + static bool isValidPersistedGuid(const QString &guid); + // Arguments for the elevated usbipd.exe, built in a fixed form from a + // validated bus id or GUID. Return an empty string for invalid input. + static QString bindArguments(const QString &busId); + static QString unbindArguments(const QString &busId, const QString &persistedGuid); + + Q_INVOKABLE void refresh(); + Q_INVOKABLE void bind(const QString &busId); + Q_INVOKABLE void unbind(const QString &busId, const QString &persistedGuid); + + QVariantList devices() const { return m_Devices; } + bool busy() const { return m_Busy; } + QString error() const { return m_Error; } + +signals: + void devicesChanged(); + void busyChanged(); + void errorChanged(); + void operationFinished(bool success, const QString &message); + +private: + explicit UsbForwardingBackend(QObject *parent = nullptr); + + void setBusy(bool busy); + void setError(const QString &error); + // Runs the validated usbipd.exe elevated with the given fixed arguments. + void runElevated(const QString &arguments, const QString &successMessage); + + QVariantList m_Devices; + bool m_Busy = false; + QString m_Error; +}; diff --git a/app/backend/usbforwardingcapability.h b/app/backend/usbforwardingcapability.h new file mode 100644 index 0000000000..970511c0e3 --- /dev/null +++ b/app/backend/usbforwardingcapability.h @@ -0,0 +1,59 @@ +#pragma once + +// Derived from qiin2333/moonlight-qt (GPL-3.0). + +#include +#include +#include +#include +#include +#include + +namespace UsbForwarding { + +// Largest capability response or tunnel handshake line we will buffer. +constexpr qsizetype kMaxHandshakeBytes = 4 * 1024; + +// A USB/IP bus id as the host accepts it: [0-9A-Za-z.:-], 1 to 31 bytes. +// Every place that forwards a bus id (tunnel handshake, usbipd command +// line) must pass it through this check first. +inline bool isValidBusId(const QByteArray& busId) noexcept { + constexpr qsizetype kMaxBusIdBytes = 31; + if (busId.isEmpty() || busId.size() > kMaxBusIdBytes) return false; + for (const char c : busId) { + const bool allowed = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || + (c >= 'A' && c <= 'Z') || c == '-' || c == '.' || c == ':'; + if (!allowed) return false; + } + return true; +} + +// Credentials stay in memory; never log or persist this object. +struct Capability { + bool available = false; + QString reason; + quint16 port = 0; + QByteArray token; + + static std::optional parse(const QByteArray& bytes) { + if (bytes.size() > kMaxHandshakeBytes) return std::nullopt; + const auto document = QJsonDocument::fromJson(bytes); + if (!document.isObject()) return std::nullopt; + const auto json = document.object(); + if (json.value("version").toDouble(-1) != 1 || + !json.value("enabled").isBool() || !json.value("available").isBool()) return std::nullopt; + Capability result; + result.available = json.value("enabled").toBool() && json.value("available").toBool(); + result.reason = json.value("reason").toString(); + if (result.available) { + const double port = json.value("port").toDouble(-1); + const QString token = json.value("token").toString(); + if (port < 1 || port > 65535 || port != static_cast(port) || + !QRegularExpression(QStringLiteral("\\A[0-9a-fA-F]{64}\\z")).match(token).hasMatch()) return std::nullopt; + result.port = static_cast(port); + result.token = token.toLatin1(); + } + return result; + } +}; +} diff --git a/app/backend/usbforwardingenvironment.cpp b/app/backend/usbforwardingenvironment.cpp new file mode 100644 index 0000000000..6ffef0079b --- /dev/null +++ b/app/backend/usbforwardingenvironment.cpp @@ -0,0 +1,545 @@ +// Derived from qiin2333/moonlight-qt (GPL-3.0). + +#include "usbforwardingenvironment.h" + +#include +#include +#include +#include +#include +#include + +#ifdef Q_OS_WIN +#include +#include +#include +#endif + +namespace { + +#ifdef Q_OS_WIN + +// "C:\..." or "C:/...". Rejects UNC paths, \\?\ paths, relative paths and +// drive-relative paths such as "C:usbipd.exe". +bool isDriveLetterAbsolute(const QString &path) +{ + static const QRegularExpression re(QStringLiteral("\\A[A-Za-z]:[\\\\/]")); + return re.match(path).hasMatch(); +} + +bool hasParentReference(const QString &path) +{ + const QStringList parts = path.split(QRegularExpression(QStringLiteral("[\\\\/]"))); + return parts.contains(QStringLiteral("..")); +} + +// Rights that let a principal change a file or what is in a directory. The +// directory-only bits share values with the file ones (FILE_ADD_FILE is +// FILE_WRITE_DATA, FILE_ADD_SUBDIRECTORY is FILE_APPEND_DATA). +constexpr DWORD kFileModifyMask = FILE_WRITE_DATA | FILE_APPEND_DATA | FILE_WRITE_EA | + FILE_WRITE_ATTRIBUTES | DELETE | WRITE_DAC | WRITE_OWNER; +constexpr DWORD kDirectoryModifyMask = kFileModifyMask | FILE_DELETE_CHILD; +// The drive root: every user may add new folders there by default, which does +// not let them replace an existing one. Only rights that can swap or +// re-permission existing children matter. +constexpr DWORD kRootModifyMask = FILE_DELETE_CHILD | DELETE | WRITE_DAC | WRITE_OWNER; + +class HandleCloser +{ +public: + explicit HandleCloser(HANDLE handle = nullptr) : m_Handle(handle) {} + ~HandleCloser() { if (m_Handle) CloseHandle(m_Handle); } + HandleCloser(const HandleCloser &) = delete; + HandleCloser &operator=(const HandleCloser &) = delete; + HANDLE get() const { return m_Handle; } + void reset(HANDLE handle) { if (m_Handle) CloseHandle(m_Handle); m_Handle = handle; } +private: + HANDLE m_Handle; +}; + +// The process's own token as a standard user would hold it, duplicated as an +// impersonation token for AccessCheck. If the process is elevated, the +// limited (linked) token is used instead, so Program Files is judged the way +// an ordinary user sees it. Returns nullptr on any failure (callers fail closed). +HANDLE openStandardUserToken() +{ + HANDLE process = nullptr; + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_DUPLICATE | TOKEN_QUERY, &process)) { + return nullptr; + } + HandleCloser processCloser(process); + HANDLE source = process; + HandleCloser linkedCloser; + TOKEN_ELEVATION_TYPE type = TokenElevationTypeDefault; + DWORD returned = 0; + if (GetTokenInformation(process, TokenElevationType, &type, sizeof(type), &returned) && + type == TokenElevationTypeFull) { + TOKEN_LINKED_TOKEN linked {}; + if (!GetTokenInformation(process, TokenLinkedToken, &linked, sizeof(linked), &returned) || + linked.LinkedToken == nullptr) { + return nullptr; + } + linkedCloser.reset(linked.LinkedToken); + source = linked.LinkedToken; + } + HANDLE impersonation = nullptr; + if (DuplicateTokenEx(source, TOKEN_QUERY | TOKEN_IMPERSONATE | TOKEN_DUPLICATE, nullptr, + SecurityImpersonation, TokenImpersonation, &impersonation)) { + return impersonation; + } + // A linked token can already be impersonation-class and refuse a duplicate; + // use a copy of the handle then. + if (source != process && + DuplicateHandle(GetCurrentProcess(), source, GetCurrentProcess(), &impersonation, 0, FALSE, + DUPLICATE_SAME_ACCESS)) { + return impersonation; + } + return nullptr; +} + +// Owner rights are implicit (WRITE_DAC), so an owner SID that this token +// carries as user or as an enabled group counts as full control. +bool ownerIsInToken(HANDLE token, PSID owner) +{ + DWORD size = 0; + GetTokenInformation(token, TokenUser, nullptr, 0, &size); + if (size == 0) { + return true; // cannot tell: fail closed + } + QByteArray userBuffer(static_cast(size), 0); + if (!GetTokenInformation(token, TokenUser, userBuffer.data(), size, &size)) { + return true; + } + if (EqualSid(reinterpret_cast(userBuffer.data())->User.Sid, owner)) { + return true; + } + size = 0; + GetTokenInformation(token, TokenGroups, nullptr, 0, &size); + if (size == 0) { + return true; + } + QByteArray groupBuffer(static_cast(size), 0); + if (!GetTokenInformation(token, TokenGroups, groupBuffer.data(), size, &size)) { + return true; + } + const auto *groups = reinterpret_cast(groupBuffer.data()); + for (DWORD i = 0; i < groups->GroupCount; ++i) { + const DWORD attributes = groups->Groups[i].Attributes; + if ((attributes & SE_GROUP_ENABLED) && !(attributes & SE_GROUP_USE_FOR_DENY_ONLY) && + EqualSid(groups->Groups[i].Sid, owner)) { + return true; + } + } + return false; +} + +// True if the token can exercise any right in mask on one object. +// Fails closed: anything unreadable or unexpected counts as writable. +bool tokenCanModify(HANDLE token, const QString &path, DWORD mask) +{ + PSECURITY_DESCRIPTOR descriptor = nullptr; + PACL dacl = nullptr; + const std::wstring wide = QDir::toNativeSeparators(path).toStdWString(); + const DWORD status = GetNamedSecurityInfoW( + wide.c_str(), SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION | GROUP_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, + nullptr, nullptr, &dacl, nullptr, &descriptor); + if (status != ERROR_SUCCESS || descriptor == nullptr) { + return true; + } + bool modifiable = true; // until proven otherwise + if (dacl != nullptr) { // a NULL DACL grants everyone full access + PSID owner = nullptr; + BOOL defaulted = FALSE; + GENERIC_MAPPING mapping = { FILE_GENERIC_READ, FILE_GENERIC_WRITE, FILE_GENERIC_EXECUTE, + FILE_ALL_ACCESS }; + PRIVILEGE_SET privileges {}; + DWORD privilegesLength = sizeof(privileges); + DWORD granted = 0; + BOOL allowed = FALSE; + if (GetSecurityDescriptorOwner(descriptor, &owner, &defaulted) && owner != nullptr && + AccessCheck(descriptor, token, MAXIMUM_ALLOWED, &mapping, &privileges, + &privilegesLength, &granted, &allowed)) { + const DWORD effective = allowed ? granted : 0; + modifiable = (effective & mask) != 0 || ownerIsInToken(token, owner); + } + } + LocalFree(descriptor); + return modifiable; +} + +// The path the file system really resolves to, with every junction, symbolic +// link, subst drive and 8.3 alias followed. Empty on failure. Qt's +// canonicalFilePath() does not follow junctions, so it cannot be used here. +QString finalPathOf(const QString &path) +{ + const std::wstring wide = QDir::toNativeSeparators(path).toStdWString(); + HANDLE handle = CreateFileW(wide.c_str(), 0, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + nullptr, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, nullptr); + if (handle == INVALID_HANDLE_VALUE) { + return QString(); + } + HandleCloser closer(handle); + std::wstring buffer(1024, L'\0'); + DWORD length = GetFinalPathNameByHandleW(handle, buffer.data(), static_cast(buffer.size()), + VOLUME_NAME_DOS); + if (length == 0 || length >= buffer.size()) { + return QString(); + } + QString result = QString::fromWCharArray(buffer.c_str(), static_cast(length)); + if (result.startsWith(QLatin1String("\\\\?\\UNC\\"))) { + return QString(); // network path + } + if (result.startsWith(QLatin1String("\\\\?\\"))) { + result.remove(0, 4); + } + return QDir::fromNativeSeparators(result); +} + +QString queryUsbipdServiceBinaryPath() +{ + const SC_HANDLE manager = OpenSCManagerW(nullptr, nullptr, SC_MANAGER_CONNECT); + if (!manager) { + return QString(); + } + QString result; + const SC_HANDLE service = OpenServiceW(manager, L"usbipd", SERVICE_QUERY_CONFIG); + if (service) { + DWORD needed = 0; + QueryServiceConfigW(service, nullptr, 0, &needed); + if (needed > 0 && needed <= 64 * 1024) { + QByteArray buffer(static_cast(needed), 0); + auto *config = reinterpret_cast(buffer.data()); + if (QueryServiceConfigW(service, config, needed, &needed) && + config->lpBinaryPathName != nullptr) { + result = QString::fromWCharArray(config->lpBinaryPathName); + } + } + CloseServiceHandle(service); + } + CloseServiceHandle(manager); + return result; +} + +// Program Files from the registry (admin-only writable), never from the +// process environment, which any user can poison for a child launch. +QStringList programFilesDirectories() +{ + QStringList dirs; + QSettings settings(QStringLiteral("HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion"), + QSettings::NativeFormat); + for (const QString &key : { QStringLiteral("ProgramW6432Dir"), QStringLiteral("ProgramFilesDir") }) { + const QString dir = settings.value(key).toString(); + if (!dir.isEmpty() && !dirs.contains(dir, Qt::CaseInsensitive)) { + dirs.append(dir); + } + } + return dirs; +} + +#endif // Q_OS_WIN + +} // namespace + +UsbForwardingEnvironment::UsbForwardingEnvironment(QObject *parent) + : QObject(parent) +{ +} + +UsbForwardingEnvironment* UsbForwardingEnvironment::get() +{ + static UsbForwardingEnvironment environment; + return &environment; +} + +QString UsbForwardingEnvironment::parseServiceBinaryPath(const QString &imagePath) +{ +#ifdef Q_OS_WIN + const QString text = imagePath.trimmed(); + QString path; + if (text.startsWith(QLatin1Char('"'))) { + const qsizetype end = text.indexOf(QLatin1Char('"'), 1); + if (end < 2) { + return QString(); + } + path = text.mid(1, end - 1); + } else { + // Unquoted paths may contain spaces: the program ends at the first ".exe". + const qsizetype idx = text.indexOf(QStringLiteral(".exe"), 0, Qt::CaseInsensitive); + if (idx < 0) { + return QString(); + } + const qsizetype end = idx + 4; + if (end < text.size() && !text.at(end).isSpace()) { + return QString(); + } + path = text.left(end); + } + if (path.contains(QLatin1Char('%')) || !isDriveLetterAbsolute(path)) { + return QString(); + } + return QDir::fromNativeSeparators(path); +#else + Q_UNUSED(imagePath); + return QString(); +#endif +} + +bool UsbForwardingEnvironment::isWritableByStandardUsers(const QString &path) +{ +#ifdef Q_OS_WIN + const QFileInfo info(path); + if (!info.exists()) { + return true; + } + HandleCloser token(openStandardUserToken()); + if (token.get() == nullptr) { + return true; + } + // The object itself, then every directory above it, including the drive root. + QString current = QDir::cleanPath(info.absoluteFilePath()); + bool first = true; + while (true) { + const QFileInfo currentInfo(current); + const bool isRoot = currentInfo.isRoot(); + const DWORD mask = isRoot ? kRootModifyMask + : ((first && !currentInfo.isDir()) ? kFileModifyMask + : kDirectoryModifyMask); + if (tokenCanModify(token.get(), current, mask)) { + return true; + } + if (isRoot) { + return false; + } + const QString parent = currentInfo.dir().absolutePath(); + if (parent == current) { + return false; + } + current = parent; + first = false; + } +#else + Q_UNUSED(path); + return true; +#endif +} + +bool UsbForwardingEnvironment::isTrustedUsbipdPath(const QString &path, QString *reason) +{ + return isTrustedExecutablePath(path, QStringLiteral("usbipd.exe"), reason); +} + +bool UsbForwardingEnvironment::isTrustedExecutablePath(const QString &path, const QString &expectedFileName, + QString *reason) +{ + const auto reject = [reason](const char *why) { + if (reason != nullptr) { + *reason = QString::fromLatin1(why); + } + return false; + }; +#ifdef Q_OS_WIN + if (path.isEmpty() || path.contains(QLatin1Char('%')) || path.contains(QLatin1Char('"')) || + path.contains(QChar(0))) { + return reject("empty or contains unsafe characters"); + } + if (!isDriveLetterAbsolute(path)) { + return reject("not an absolute drive-letter path"); + } + if (hasParentReference(path)) { + return reject("contains a parent directory reference"); + } + const QString cleaned = QDir::cleanPath(path); + const QFileInfo info(cleaned); + if (info.fileName().compare(expectedFileName, Qt::CaseInsensitive) != 0) { + return reject("file name is not the expected one"); + } + if (!info.exists() || !info.isFile()) { + return reject("file does not exist"); + } + if (finalPathOf(cleaned).compare(cleaned, Qt::CaseInsensitive) != 0) { + return reject("path goes through a link or alias"); + } + if (isWritableByStandardUsers(cleaned)) { + return reject("location is writable by standard users"); + } + return true; +#else + Q_UNUSED(path); + Q_UNUSED(expectedFileName); + return reject("unsupported platform"); +#endif +} + +QStringList UsbForwardingEnvironment::candidateUsbipdPaths() +{ + // Never QStandardPaths::findExecutable(), PATH, the current directory or + // environment variables: the result may be launched elevated. + QStringList candidates; +#ifdef Q_OS_WIN + const QString servicePath = parseServiceBinaryPath(queryUsbipdServiceBinaryPath()); + if (!servicePath.isEmpty()) { + candidates.append(servicePath); + } + for (const QString &dir : programFilesDirectories()) { + candidates.append(QDir::fromNativeSeparators(dir) + QStringLiteral("/usbipd-win/usbipd.exe")); + } +#endif + return candidates; +} + +QString UsbForwardingEnvironment::firstAccepted(const QStringList &candidates, + const std::function &accept) +{ + for (const QString &candidate : candidates) { + if (accept(candidate)) { + return QDir::cleanPath(candidate); + } + } + return QString(); +} + +QString UsbForwardingEnvironment::locateUsbipd() +{ + return firstAccepted(candidateUsbipdPaths(), + [](const QString &path) { return isTrustedUsbipdPath(path); }); +} + +void UsbForwardingEnvironment::refresh() +{ + if (m_Checking) { + return; + } +#ifdef Q_OS_WIN + const QString usbipdExe = locateUsbipd(); + if (usbipdExe.isEmpty()) { + m_Version.clear(); + finish(NotInstalled); + return; + } + probeWith(usbipdExe, { QStringLiteral("--version") }, 8000); +#else + m_Version.clear(); + finish(Unsupported); +#endif +} + +void UsbForwardingEnvironment::probeWith(const QString &program, const QStringList &arguments, + int timeoutMs) +{ + if (m_Checking) { + return; + } + m_Checking = true; + emit checkingChanged(); + m_State = Checking; + emit stateChanged(); + + QProcess *probe = new QProcess(this); + /* FailedToStart emits errorOccurred but never finished; handle it so a + * missing executable cannot wedge the probe. */ + connect(probe, &QProcess::errorOccurred, this, + [this, probe](QProcess::ProcessError processError) { + if (processError != QProcess::FailedToStart) { + return; + } + probe->deleteLater(); + finish(ServiceStopped); + }); + connect(probe, &QProcess::finished, this, + [this, probe](int exitCode, QProcess::ExitStatus exitStatus) { + probe->deleteLater(); + // A probe we had to kill, or one that crashed, tells us nothing about + // the service: report that the check failed, not that it is stopped. + if (probe->property("timedOut").toBool() || exitStatus != QProcess::NormalExit) { + finish(CheckFailed); + return; + } + if (exitCode != 0) { + finish(ServiceStopped); + return; + } + const QString output = + QString::fromLocal8Bit(probe->readAllStandardOutput()); + const QString firstLine = output.section(QLatin1Char('\n'), 0, 0).simplified(); + // "usbipd-win 4.2.0" -> "4.2.0" + QString version = firstLine; + if (version.startsWith(QLatin1String("usbipd-win"), Qt::CaseInsensitive)) { + version.remove(0, 10); + } + version = version.trimmed(); + m_Version = version; + startServiceProbe(); + }); + QTimer::singleShot(timeoutMs, probe, [probe] { + if (probe->state() != QProcess::NotRunning) { + probe->setProperty("timedOut", true); + probe->kill(); + } + }); + // Run from the program's own directory, never from the caller's cwd. + probe->setWorkingDirectory(QFileInfo(program).absolutePath()); + probe->start(program, arguments); +} + +void UsbForwardingEnvironment::startServiceProbe() +{ + finish(probeServices()); +} + +UsbForwardingEnvironment::State UsbForwardingEnvironment::probeServices() +{ +#ifdef Q_OS_WIN + const SC_HANDLE manager = OpenSCManagerW(nullptr, nullptr, SC_MANAGER_CONNECT); + if (!manager) return CheckFailed; + State result = Ready; + const struct { const wchar_t* name; State stopped; } services[] = { + {L"usbipd", ServiceStopped}, {L"VBoxUSBMon", DriverStopped} + }; + for (const auto& entry : services) { + const SC_HANDLE service = OpenServiceW(manager, entry.name, SERVICE_QUERY_STATUS); + if (!service) { + result = CheckFailed; + break; + } + SERVICE_STATUS status {}; + const bool queried = QueryServiceStatus(service, &status) != FALSE; + CloseServiceHandle(service); + if (!queried || status.dwCurrentState != SERVICE_RUNNING) { + result = queried ? entry.stopped : CheckFailed; + break; + } + } + CloseServiceHandle(manager); + return result; +#else + return Unsupported; +#endif +} + +QString UsbForwardingEnvironment::readinessError(State state) +{ + switch (state) { + case Ready: + return {}; + case Unsupported: + return tr("USB forwarding is not supported on this platform."); + case DriverStopped: + return tr("The USB forwarding driver is not running. Start VBoxUSBMon as administrator, or restart Windows."); + case ServiceStopped: + return tr("The usbipd service is not running. Start the service and retry."); + case Checking: + case NotInstalled: + case CheckFailed: + default: + return tr("Could not verify the local USB service and driver. Check the usbipd-win installation."); + } +} + +void UsbForwardingEnvironment::finish(State state) +{ + m_State = state; + m_Checking = false; + emit stateChanged(); + emit checkingChanged(); +} diff --git a/app/backend/usbforwardingenvironment.h b/app/backend/usbforwardingenvironment.h new file mode 100644 index 0000000000..082a98fb4a --- /dev/null +++ b/app/backend/usbforwardingenvironment.h @@ -0,0 +1,101 @@ +#pragma once + +// Derived from qiin2333/moonlight-qt (GPL-3.0). + +#include +#include +#include +#include + +// Read-only health check of the local USB sharing stack (usbipd-win and its +// driver). Windows only; on other platforms every query reports Unsupported. +// +// SECURITY: this class is where the client decides which usbipd.exe it will +// run, sometimes elevated. It never searches PATH or the current directory. +// The executable must come from the usbipd Windows service's registered +// binary path or from %ProgramFiles%\usbipd-win, and must pass +// isTrustedUsbipdPath() (absolute, named usbipd.exe, not reachable through +// a link, and not writable by standard users anywhere along its path). +class UsbForwardingEnvironment : public QObject +{ + Q_OBJECT + Q_PROPERTY(State state READ state NOTIFY stateChanged) + Q_PROPERTY(QString usbipdVersion READ usbipdVersion NOTIFY stateChanged) + Q_PROPERTY(bool checking READ checking NOTIFY checkingChanged) + +public: + enum State + { + Checking, + NotInstalled, + ServiceStopped, + Ready, + DriverStopped, + CheckFailed, + Unsupported, + }; + Q_ENUM(State) + + static UsbForwardingEnvironment* get(); + + // Absolute, validated path of usbipd.exe, or an empty string. + static QString locateUsbipd(); + // The only places usbipd.exe is ever looked for (service binary path, + // then Program Files from the registry). Exposed so tests can prove PATH, + // the working directory and environment variables are never candidates. + static QStringList candidateUsbipdPaths(); + // First candidate the predicate accepts. locateUsbipd() uses + // isTrustedUsbipdPath as the predicate; tests pass an accept-all one. + static QString firstAccepted(const QStringList &candidates, + const std::function &accept); + // Pulls the executable out of a service ImagePath such as + // "C:\Program Files\usbipd-win\usbipd.exe" server. Returns an empty + // string for anything that is not a plain absolute path (relative paths, + // %VARIABLES%, empty input). Pure string work, no file access. + static QString parseServiceBinaryPath(const QString &imagePath); + // True only if path is an absolute drive-letter path to a real file named + // usbipd.exe, with no links in the way, that standard users cannot + // replace. On failure reason (optional) says why. Always false off Windows. + static bool isTrustedUsbipdPath(const QString &path, QString *reason = nullptr); + // The same checks for any expected file name. Exposed so tests can run the + // positive case against a real system file; production code only calls + // isTrustedUsbipdPath(). + static bool isTrustedExecutablePath(const QString &path, const QString &expectedFileName, + QString *reason = nullptr); + // True if this process's own standard-user token (the limited token when + // the process is elevated) could modify the file, or any directory above + // it up to and including the drive root. Decided with a real AccessCheck, + // so group membership and ownership count. At the drive root only rights + // that can replace or re-permission existing children count, because the + // default root ACL lets any user add new folders. Any API error, NULL + // DACL or unreadable ACL counts as writable. Always true off Windows. + static bool isWritableByStandardUsers(const QString &path); + + // Test seam: runs `program arguments` as the version probe with the given + // timeout, exactly as refresh() does with usbipd.exe --version. + void probeWith(const QString &program, const QStringList &arguments, int timeoutMs); + + // Read-only SCM queries; also used immediately before a tunnel attempt. + static State probeServices(); + static QString readinessError(State state); + + Q_INVOKABLE void refresh(); + + State state() const { return m_State; } + QString usbipdVersion() const { return m_Version; } + bool checking() const { return m_Checking; } + +signals: + void stateChanged(); + void checkingChanged(); + +private: + explicit UsbForwardingEnvironment(QObject *parent = nullptr); + + void startServiceProbe(); + void finish(State state); + + State m_State = Checking; + QString m_Version; + bool m_Checking = false; +}; diff --git a/app/backend/usbforwardingtunnel.cpp b/app/backend/usbforwardingtunnel.cpp new file mode 100644 index 0000000000..00f798bfae --- /dev/null +++ b/app/backend/usbforwardingtunnel.cpp @@ -0,0 +1,473 @@ +// Derived from qiin2333/moonlight-qt (GPL-3.0). + +#include "usbforwardingtunnel.h" +#include "usbforwardingcapability.h" +#include "pairedcertificate.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace UsbForwarding { + +namespace { +/* Stop reading from one side while the other side is this far behind. TCP + * back-pressures the USB/IP peer instead of us buffering without limit. */ +constexpr qint64 kHighWaterMark = 4 * 1024 * 1024; + +/* The host's error text is untrusted: it must never reach the user as free + * text (it could be markup or an attempt to impersonate system messages). + * Only a few known codes are mapped; everything else gets a generic message. */ +QString describeHostReason(const QString &reason) +{ + const QString code = reason.trimmed().toLower(); + if (code == QLatin1String("disabled")) { + return Tunnel::tr("USB forwarding is disabled on the host."); + } + if (code == QLatin1String("unavailable")) { + return Tunnel::tr("USB forwarding is not available on the host."); + } + if (code == QLatin1String("unauthorized") || code == QLatin1String("forbidden")) { + return Tunnel::tr("The host did not allow USB forwarding for this device."); + } + if (code == QLatin1String("busy")) { + return Tunnel::tr("That USB device is already in use on the host."); + } + return QString(); +} +} // namespace + +bool TunnelConfig::valid() const noexcept +{ + if (host.isEmpty() || port == 0 || sessionToken.isEmpty() || busId.isEmpty() || + localPort == 0) { + return false; + } + /* Mirror the server's valid_busid(): [0-9A-Za-z.:-], at most 31 bytes. */ + if (!isValidBusId(busId)) { + return false; + } + /* The host authenticates us by client certificate: refuse to try without one. */ + if (sslConfiguration.localCertificate().isNull() || sslConfiguration.privateKey().isNull()) { + return false; + } + if (startupTimeoutMs <= 0) { + return false; + } + /* The local hop is plaintext USB/IP: it must stay on this machine. */ + if (!QHostAddress(localHost).isLoopback()) { + return false; + } + return true; +} + +/* Socket I/O must continue while Session's SDL loop owns the GUI thread. + * Keep the transport and its timers together on one dedicated event loop. + * Design from aldobarr's qiin2333/moonlight-qt#241 IO-thread rework, adopted after his + * mouse-latency measurements reproduced on qiin2333/moonlight-qt#242. User-facing strings stay + * in Tunnel's tr() context so existing translations keep matching. */ +class TunnelWorker final : public QObject +{ + Q_OBJECT + +public: + explicit TunnelWorker(TunnelConfig config, QObject *parent = nullptr); + ~TunnelWorker() override; + + bool start(QString *error = nullptr); + void stop() noexcept; + +signals: + void forwarding(); + void finished(QString message); + +private: + void handleRemoteReadyRead(); + void handleLocalReadyRead(); + void failWith(const QString &message); + void finishCleanly(); + + TunnelConfig m_Config; + QTcpSocket *m_Local = nullptr; + QSslSocket *m_Remote = nullptr; + QTimer *m_StartupTimer = nullptr; + QByteArray m_HandshakeBuffer; + bool m_HandshakeDone = false; + bool m_PeerVerified = false; + bool m_Finished = false; +}; + +TunnelWorker::TunnelWorker(TunnelConfig config, QObject *parent) + : QObject(parent), m_Config(std::move(config)) +{ + m_StartupTimer = new QTimer(this); + m_StartupTimer->setSingleShot(true); + connect(m_StartupTimer, &QTimer::timeout, this, + [this] { failWith(Tunnel::tr("The USB tunnel connection timed out.")); }); +} + +TunnelWorker::~TunnelWorker() +{ + stop(); +} + +bool TunnelWorker::start(QString *error) +{ + if (!m_Config.valid()) { + if (error != nullptr) { + *error = Tunnel::tr("The USB tunnel configuration is incomplete."); + } + return false; + } + if (m_Local != nullptr || m_Remote != nullptr) { + if (error != nullptr) { + *error = Tunnel::tr("The USB tunnel is already running."); + } + return false; + } + + if (m_Config.pinnedServerCertificate.isNull()) { + /* A forwarded USB device is a high-trust channel: never fall back to + * default CA verification. Require the cert pinned at pairing time. */ + if (error != nullptr) { + *error = Tunnel::tr("Pair with this host before forwarding USB devices."); + } + return false; + } + + m_Local = new QTcpSocket(this); + m_Remote = new QSslSocket(this); + m_Finished = false; + m_HandshakeDone = false; + m_PeerVerified = false; + m_HandshakeBuffer.clear(); + m_Local->setReadBufferSize(kHighWaterMark); + m_Remote->setReadBufferSize(kHighWaterMark); + + /* Verification is manual (mirrors nvhttp): Qt's chain/hostname checks + * cannot express "dial by IP + trust exactly this generic-CN self-signed + * certificate", so the handshake runs unverified and the encrypted + * callback below rejects anything whose DER differs from the pin. */ + QSslConfiguration sslConfig = m_Config.sslConfiguration; + sslConfig.setPeerVerifyMode(QSslSocket::VerifyNone); + sslConfig.setProtocol(QSsl::TlsV1_2OrLater); + m_Remote->setSslConfiguration(sslConfig); + + connect(m_Remote, &QSslSocket::connected, this, [this] { + /* USB interrupt reports must not wait for Nagle's algorithm and a + * delayed peer ACK. Set this after the native socket exists. */ + m_Remote->setSocketOption(QAbstractSocket::LowDelayOption, 1); + }); + connect(m_Remote, &QSslSocket::encrypted, this, [this] { + if (!PairedCertificate::matches(m_Config.pinnedServerCertificate, + m_Remote->peerCertificate())) { + failWith(Tunnel::tr("The host certificate was rejected: unexpected certificate")); + return; + } + m_PeerVerified = true; + /* The handshake is the only Moonlight-owned protocol on this socket. */ + QJsonObject request{ + { QStringLiteral("op"), QStringLiteral("forward") }, + { QStringLiteral("token"), QString::fromUtf8(m_Config.sessionToken) }, + { QStringLiteral("busid"), QString::fromUtf8(m_Config.busId) }, + }; + QByteArray line = QJsonDocument(request).toJson(QJsonDocument::Compact); + line.append('\n'); + m_Remote->write(line); + }); + connect(m_Remote, &QSslSocket::readyRead, this, &TunnelWorker::handleRemoteReadyRead); + connect(m_Remote, &QSslSocket::disconnected, this, [this] { finishCleanly(); }); +#if QT_VERSION >= QT_VERSION_CHECK(5, 15, 0) + connect(m_Remote, &QSslSocket::errorOccurred, this, [this](QAbstractSocket::SocketError) { + failWith( + Tunnel::tr("The connection to the host was lost: %1").arg(m_Remote->errorString())); + }); +#else + connect( + m_Remote, QOverload::of(&QAbstractSocket::error), this, + [this](QAbstractSocket::SocketError) { + failWith( + Tunnel::tr("The connection to the host was lost: %1").arg(m_Remote->errorString())); + }); +#endif + connect(m_Remote, QOverload &>::of(&QSslSocket::sslErrors), this, + [this](const QList &errors) { + // Pairing pins the exact certificate, independent of hostname/CA + // validity. Never ignore errors for a different peer certificate. + if (PairedCertificate::matches(m_Config.pinnedServerCertificate, + m_Remote->peerCertificate())) { + m_Remote->ignoreSslErrors(errors); + return; + } + QStringList messages; + for (const QSslError &sslError : errors) { + messages.append(sslError.errorString()); + } + failWith(Tunnel::tr("The host certificate was rejected: %1") + .arg(messages.join(QStringLiteral("; ")))); + }); + /* Drain the peer once our queue empties so the pump resumes after a + * high-water pause. */ + connect(m_Remote, &QSslSocket::bytesWritten, this, [this](qint64) { handleLocalReadyRead(); }); + + connect(m_Local, &QTcpSocket::readyRead, this, &TunnelWorker::handleLocalReadyRead); + connect(m_Local, &QTcpSocket::connected, this, [this] { + /* The loopback hop carries the same latency-sensitive USB requests. */ + m_Local->setSocketOption(QAbstractSocket::LowDelayOption, 1); + handleRemoteReadyRead(); + }); + connect(m_Local, &QTcpSocket::disconnected, this, [this] { finishCleanly(); }); +#if QT_VERSION >= QT_VERSION_CHECK(5, 15, 0) + connect(m_Local, &QTcpSocket::errorOccurred, this, [this](QAbstractSocket::SocketError) { + failWith( + Tunnel::tr("The local USB service connection failed: %1").arg(m_Local->errorString())); + }); +#else + connect(m_Local, QOverload::of(&QAbstractSocket::error), this, + [this](QAbstractSocket::SocketError) { + failWith(Tunnel::tr("The local USB service connection failed: %1") + .arg(m_Local->errorString())); + }); +#endif + connect(m_Local, &QTcpSocket::bytesWritten, this, [this](qint64) { handleRemoteReadyRead(); }); + + m_Local->connectToHost(m_Config.localHost, m_Config.localPort); + m_Remote->connectToHostEncrypted(m_Config.host, m_Config.port); + /* Deadline covering TCP connect, TLS, the JSON handshake, and the host-side + * usbip attach that must finish before the server sends its ready line + * (the server allows 12 s for the same window; the client must outlast it). */ + m_StartupTimer->start(m_Config.startupTimeoutMs); + return true; +} + +void TunnelWorker::stop() noexcept +{ + m_Finished = true; + m_StartupTimer->stop(); + if (m_Local != nullptr) { + m_Local->disconnect(this); + m_Local->abort(); + m_Local->deleteLater(); + m_Local = nullptr; + } + if (m_Remote != nullptr) { + m_Remote->disconnect(this); + m_Remote->abort(); + m_Remote->deleteLater(); + m_Remote = nullptr; + } +} + +void TunnelWorker::handleRemoteReadyRead() +{ + if (m_Finished || !m_PeerVerified || m_Remote == nullptr || m_Local == nullptr || + m_Local->state() != QAbstractSocket::ConnectedState) { + return; + } + + if (!m_HandshakeDone) { + m_HandshakeBuffer.append(m_Remote->readAll()); + const qsizetype newline = m_HandshakeBuffer.indexOf('\n'); + if (newline < 0) { + if (m_HandshakeBuffer.size() > kMaxHandshakeBytes) { + failWith(Tunnel::tr("The host sent an invalid USB tunnel response.")); + } + return; + } + if (newline > kMaxHandshakeBytes) { + /* Content alone exceeds the mirrored server limit; the server + * would have closed at the same boundary. */ + failWith(Tunnel::tr("The host sent an invalid USB tunnel response.")); + return; + } + const QByteArray line = m_HandshakeBuffer.left(newline); + m_HandshakeBuffer.remove(0, newline + 1); + + const QJsonObject reply = QJsonDocument::fromJson(line).object(); + const QString op = reply.value(QStringLiteral("op")).toString(); + if (op != QStringLiteral("ready")) { + const QString reason = describeHostReason(reply.value(QStringLiteral("reason")).toString()); + failWith(reason.isEmpty() + ? Tunnel::tr("The host could not start USB forwarding.") + : Tunnel::tr("The host could not start USB forwarding: %1").arg(reason)); + return; + } + m_HandshakeDone = true; + m_StartupTimer->stop(); + emit forwarding(); + if (m_Finished) + return; + /* Residual bytes past the handshake line stay in m_HandshakeBuffer and + * are flushed through the bounded pump below — never written directly, + * so kHighWaterMark always applies. */ + } + + /* Bounded opaque byte pump: host -> local USB/IP server. Handshake + * residual bytes and fresh socket reads share the same high-water limit; + * m_Local's bytesWritten signal resumes this pump after a pause. */ + while (!m_Finished && m_Local && m_Remote && m_Local->bytesToWrite() < kHighWaterMark) { + QByteArray chunk; + if (!m_HandshakeBuffer.isEmpty()) { + chunk = m_HandshakeBuffer.left(64 * 1024); + m_HandshakeBuffer.remove(0, chunk.size()); + } else if (m_Remote->bytesAvailable() > 0) { + chunk = m_Remote->read(64 * 1024); + } else { + break; + } + if (chunk.isEmpty()) { + break; + } + m_Local->write(chunk); + } + + /* Drain anything the local USB/IP server queued before we were ready. */ + if (!m_Finished && m_Local && m_HandshakeBuffer.isEmpty() && + m_Local->bytesToWrite() < kHighWaterMark) { + handleLocalReadyRead(); + } +} + +void TunnelWorker::handleLocalReadyRead() +{ + if (m_Remote == nullptr || m_Local == nullptr || !m_HandshakeDone) { + /* Hold local data until the authenticated tunnel is ready. USB/IP + * import must flow before the host's attach command can complete. */ + return; + } + + /* Opaque byte pump: local USB/IP server -> host. */ + while (!m_Finished && m_Local && m_Remote && m_Local->bytesAvailable() > 0 && + m_Remote->bytesToWrite() < kHighWaterMark) { + const QByteArray chunk = m_Local->read(64 * 1024); + if (chunk.isEmpty()) { + break; + } + m_Remote->write(chunk); + } +} + +void TunnelWorker::failWith(const QString &message) +{ + if (m_Finished) { + return; + } + stop(); + emit finished(message); +} + +void TunnelWorker::finishCleanly() +{ + if (m_Finished) { + return; + } + if (!m_HandshakeDone) { + failWith(Tunnel::tr("The USB tunnel closed before the connection was ready.")); + return; + } + stop(); + emit finished(QString()); +} + +class Tunnel::Impl +{ +public: + explicit Impl(TunnelConfig configuration) : config(std::move(configuration)) {} + + TunnelConfig config; + QThread ioThread; + TunnelWorker *worker = nullptr; + quint64 generation = 0; +}; + +Tunnel::Tunnel(TunnelConfig config, QObject *parent) + : QObject(parent), m_Impl(std::make_unique(std::move(config))) +{ +} + +Tunnel::~Tunnel() +{ + stop(); +} + +bool Tunnel::start(QString *error) +{ + if (!m_Impl->config.valid()) { + if (error != nullptr) { + *error = tr("The USB tunnel configuration is incomplete."); + } + return false; + } + if (m_Impl->worker != nullptr) { + if (error != nullptr) { + *error = tr("The USB tunnel is already running."); + } + return false; + } + if (m_Impl->config.pinnedServerCertificate.isNull()) { + if (error != nullptr) { + *error = tr("Pair with this host before forwarding USB devices."); + } + return false; + } + + auto *worker = new TunnelWorker(m_Impl->config); + m_Impl->worker = worker; + const quint64 generation = ++m_Impl->generation; + worker->moveToThread(&m_Impl->ioThread); + connect(&m_Impl->ioThread, &QThread::started, worker, [worker] { + QString startError; + if (!worker->start(&startError)) { + emit worker->finished(startError); + } + }); + connect(worker, &TunnelWorker::forwarding, this, [this, generation] { + if (m_Impl->worker != nullptr && generation == m_Impl->generation) { + emit forwarding(); + } + }); + connect(worker, &TunnelWorker::finished, this, [this, generation](const QString &message) { + if (m_Impl->worker == nullptr || generation != m_Impl->generation) { + return; + } + stop(); + emit finished(message); + }); + m_Impl->ioThread.start(); + return true; +} + +void Tunnel::stop() noexcept +{ + TunnelWorker *worker = m_Impl->worker; + if (worker == nullptr) { + return; + } + m_Impl->worker = nullptr; + ++m_Impl->generation; + worker->disconnect(this); + /* Close sockets on their owning thread before returning, so device + * restoration cannot race with a still-open USB/IP connection. This + * does not wait for network I/O or any GUI-thread callback. */ + if (m_Impl->ioThread.isRunning()) { + QMetaObject::invokeMethod( + worker, [worker] { worker->stop(); }, Qt::BlockingQueuedConnection); + } else { + worker->stop(); + } + m_Impl->ioThread.quit(); + m_Impl->ioThread.wait(); + /* deleteLater posted around thread exit never gets processed on a dead + * loop; the owner deletes the worker outright once the thread joined. */ + delete worker; +} + +} // namespace UsbForwarding + +#include "usbforwardingtunnel.moc" diff --git a/app/backend/usbforwardingtunnel.h b/app/backend/usbforwardingtunnel.h new file mode 100644 index 0000000000..0d635544e1 --- /dev/null +++ b/app/backend/usbforwardingtunnel.h @@ -0,0 +1,88 @@ +#pragma once + +// Derived from qiin2333/moonlight-qt (GPL-3.0). + +/* + * Reverse USB/IP tunnel client. + * + * One tunnel carries one USB device. The client owns two sockets and copies + * bytes between them without interpreting a single USB/IP byte: + * + * local : TCP to the local USB/IP server (usbipd-win, port 3240) + * remote : TLS to ApolloVibe, authenticated with the paired client + * certificate, carrying the configured shared token + * + * Transport sockets run on a dedicated worker thread with its own event + * loop, independent of the Session/GUI loop: a forwarded 1000 Hz mouse + * produces a continuous URB stream, and pumping it on the Session loop + * starves input processing (measured in qiin2333/moonlight-qt#241 and qiin2333/moonlight-qt#242, aldobarr's mouse + * latency reports). Notifications are relayed back to the owner thread. + * + * Session owns the tunnel and closes it when streaming ends. The tunnel uses + * a separate socket from video/audio/control; its port and token come from + * the GET /api/v1/usb-forwarding capability endpoint. + * + * Only the handshake is Moonlight's own protocol: one line of JSON in each + * direction. Everything after that is an opaque byte stream. + */ + +#include +#include +#include +#include +#include + +namespace UsbForwarding { + +struct TunnelConfig +{ + /* Configured ApolloVibe USB endpoint. */ + QString host; + quint16 port = 0; + /* Shared token configured on both ends; not yet a per-session credential. */ + QByteArray sessionToken; + /* Paired client certificate/key plus the pinned server certificate. */ + QSslConfiguration sslConfiguration; + QSslCertificate pinnedServerCertificate; + + /* Bus id of the local USB/IP server, forwarded to ApolloVibe verbatim. */ + QByteArray busId; + + /* Local USB/IP server endpoint. */ + QString localHost = QStringLiteral("127.0.0.1"); + quint16 localPort = 3240; + + /* Startup deadline in milliseconds; tests shorten it. */ + int startupTimeoutMs = 15 * 1000; + + bool valid() const noexcept; +}; + +class Tunnel final : public QObject +{ + Q_OBJECT + +public: + explicit Tunnel(TunnelConfig config, QObject *parent = nullptr); + ~Tunnel() override; + + Q_DISABLE_COPY(Tunnel) + + // Call on this object's owning thread. Notifications return to that + // thread; sockets and timers are serviced by a private worker thread. + bool start(QString *error = nullptr); + // Closes the transport and joins its worker before returning. + void stop() noexcept; + +signals: + /* The authenticated byte tunnel is ready for USB/IP import/enumeration. */ + void forwarding(); + /* Terminal: the tunnel is done. message is empty on a clean stop. */ + void finished(QString message); + +private: + class Impl; + std::unique_ptr m_Impl; +}; + +} // namespace UsbForwarding diff --git a/tests/run_usb_forwarding_tests.ps1 b/tests/run_usb_forwarding_tests.ps1 new file mode 100644 index 0000000000..a09ba88bb4 --- /dev/null +++ b/tests/run_usb_forwarding_tests.ps1 @@ -0,0 +1,91 @@ +# Builds and runs the USB forwarding unit tests with qmake + MSVC. +# +# .\tests\run_usb_forwarding_tests.ps1 -OutDir C:\somewhere\outside\the\repo [-Tests name,name] +# +# Everything uses loopback sockets and throwaway certificates generated into +# OutDir. Nothing is installed, no service or device is touched. +param( + [Parameter(Mandatory = $true)][string]$OutDir, + [string[]]$Tests = @('usb_forwarding_capability', 'usb_forwarding_environment', + 'usb_forwarding_backend_list', 'usb_forwarding_tunnel', 'usb_forwarding_tunnel_clientcert', + 'usb_forwarding_nvhttp'), + [string]$QtDir = 'C:\Qt\6.10.3\msvc2022_64', + [string]$VcVars = 'C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\VC\Auxiliary\Build\vcvars64.bat', + [string]$OpenSsl = 'C:\Program Files\Git\usr\bin\openssl.exe' +) + +$ErrorActionPreference = 'Stop' +$testsRoot = $PSScriptRoot +$repoRoot = (Resolve-Path (Join-Path $testsRoot '..')).Path +$OutDir = [System.IO.Path]::GetFullPath($OutDir) +# Compare with a trailing separator so a sibling such as "-verify" is not +# mistaken for a path inside "". +$sep = [System.IO.Path]::DirectorySeparatorChar +$repoPrefix = $repoRoot.TrimEnd('\', '/') + $sep +if (($OutDir.TrimEnd('\', '/') + $sep).StartsWith($repoPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + throw 'OutDir must be outside the repository.' +} + +# Throwaway certificates: "a" is the paired host, "b" is an impostor. +$certDir = Join-Path $OutDir 'certs' +New-Item -ItemType Directory -Force $certDir | Out-Null +foreach ($name in 'a', 'b') { + $cert = Join-Path $certDir "cert-$name.pem" + $key = Join-Path $certDir "key-$name.pem" + if (-not (Test-Path $cert) -or -not (Test-Path $key)) { + # openssl prints key-generation progress on stderr; Windows PowerShell + # would treat that as an error under 'Stop'. + $ErrorActionPreference = 'Continue' + & $OpenSsl req -x509 -newkey rsa:2048 -nodes -keyout $key -out $cert -days 2 ` + -subj "/CN=usbfwd-test-$name" -addext "subjectAltName=IP:127.0.0.1" 2>&1 | Out-Null + $ErrorActionPreference = 'Stop' + if ($LASTEXITCODE -ne 0) { throw "openssl failed for $name" } + } +} +$env:USBFWD_TEST_CERTS = $certDir + +# PATH without any OpenSSL DLL directory, so Qt keeps Schannel (the Windows +# default) unless a test asks for OpenSSL. +$basePath = ($env:PATH -split ';' | Where-Object { + $_ -and -not (Test-Path (Join-Path $_ 'libssl-3-x64.dll')) -and -not (Test-Path (Join-Path $_ 'libssl-1_1-x64.dll')) + }) -join ';' +$failed = @() +foreach ($test in $Tests) { + $src = Join-Path $testsRoot $test + $out = Join-Path $OutDir $test + New-Item -ItemType Directory -Force $out | Out-Null + $log = Join-Path $out 'build.log' + $cmd = "call `"$VcVars`" >nul && cd /d `"$out`" && `"$QtDir\bin\qmake.exe`" `"$src\$test.pro`" " + + "`"CONFIG+=release`" `"CONFIG-=debug_and_release`" && nmake /nologo" + cmd /c "$cmd > `"$log`" 2>&1" + if ($LASTEXITCODE -ne 0) { + Write-Host "BUILD FAILED: $test (see $log)" + Get-Content $log -Tail 25 + $failed += $test + continue + } + $exe = Get-ChildItem -Path $out -Recurse -Filter '*_test.exe' | Select-Object -First 1 + # OpenSSL DLLs (from Git for Windows) go last so they never shadow Qt's own. + $gitRoot = Split-Path (Split-Path (Split-Path $OpenSsl)) + $env:PATH = "$QtDir\bin;" + $basePath + # Schannel is the Windows default; only the client-certificate test needs + # OpenSSL (Schannel servers never request a client certificate). + $env:QT_TLS_BACKEND = 'schannel' + if ($test -like '*clientcert*') { + $env:PATH += ";" + (Join-Path $gitRoot 'mingw64\bin') + $env:QT_TLS_BACKEND = 'openssl' + } + $result = Join-Path $out 'result.txt' + & $exe.FullName -o "$result,txt" + $code = $LASTEXITCODE + Write-Host "== $test (exit $code)" + if (Test-Path $result) { + Get-Content $result | Where-Object { $_ -match '^(FAIL|SKIP|Totals)|Actual|Expected|failure location' } | Out-Host + } + if ($code -ne 0) { $failed += $test } +} +if ($failed.Count -gt 0) { + Write-Host "FAILED: $($failed -join ', ')" + exit 1 +} +Write-Host 'ALL PASSED' diff --git a/tests/usb_forwarding_backend_list/main.cpp b/tests/usb_forwarding_backend_list/main.cpp new file mode 100644 index 0000000000..f7c56a30f2 --- /dev/null +++ b/tests/usb_forwarding_backend_list/main.cpp @@ -0,0 +1,169 @@ +// `usbipd state` parsing and the elevated command lines, without running +// usbipd or showing a UAC prompt. +// Derived from qiin2333/moonlight-qt (GPL-3.0). +#include "../../app/backend/usbforwardingbackend.h" + +#include +#include + +class BackendListTest : public QObject +{ + Q_OBJECT + + static QVariantList parse(const QByteArray &json, QString *error) + { + return UsbForwardingBackend::parseUsbipdState(json, error); + } + + static QVariantMap onlyDevice(const QByteArray &json) + { + QString error; + const QVariantList devices = parse(json, &error); + return (error.isEmpty() && devices.size() == 1) ? devices.first().toMap() : QVariantMap(); + } + +private slots: + void sharedDevice() + { + const QVariantMap device = onlyDevice( + R"({"Devices":[{"BusId":"1-2","ClientIPAddress":null,"Description":"DualSense Wireless Controller",)" + R"("InstanceId":"USB\\VID_054C&PID_0CE6\\5&1&0&2","IsForced":false,)" + R"("PersistedGuid":"9b6a62f8-0123-4567-89ab-0123456789ab","StubInstanceId":null}]})"); + QVERIFY(!device.isEmpty()); + QCOMPARE(device.value("busId").toString(), QStringLiteral("1-2")); + QCOMPARE(device.value("description").toString(), QStringLiteral("DualSense Wireless Controller")); + QCOMPARE(device.value("vidPid").toString(), QStringLiteral("054c:0ce6")); + QVERIFY(device.value("isBound").toBool()); + QVERIFY(device.value("isConnected").toBool()); + QVERIFY(!device.value("isAttached").toBool()); + QVERIFY(device.value("isSupported").toBool()); + QVERIFY(!device.value("isForced").toBool()); + QCOMPARE(device.value("persistedGuid").toString(), QStringLiteral("9b6a62f8-0123-4567-89ab-0123456789ab")); + } + + void attachedAndUnboundDevice() + { + const QVariantMap device = onlyDevice( + R"({"Devices":[{"BusId":"3-1","ClientIPAddress":"192.168.1.9","Description":"Keyboard",)" + R"("InstanceId":"USB\\VID_0001&PID_0002\\x","IsForced":true,"PersistedGuid":null}]})"); + QVERIFY(!device.isEmpty()); + QVERIFY(!device.value("isBound").toBool()); + QVERIFY(device.value("isAttached").toBool()); + QVERIFY(device.value("isForced").toBool()); + QVERIFY(device.value("isSupported").toBool()); + } + + void disconnectedAndIncompatibleDevicesAreNotSupported() + { + // Persisted share whose device is unplugged: BusId is null. + const QVariantMap unplugged = onlyDevice( + R"({"Devices":[{"BusId":null,"Description":"Gone","InstanceId":"USB\\VID_0001&PID_0002\\x",)" + R"("PersistedGuid":"9b6a62f8-0123-4567-89ab-0123456789ab"}]})"); + QVERIFY(!unplugged.isEmpty()); + QVERIFY(!unplugged.value("isConnected").toBool()); + QVERIFY(!unplugged.value("isSupported").toBool()); + QVERIFY(unplugged.value("isBound").toBool()); + + const QVariantMap hub = onlyDevice(R"({"Devices":[{"BusId":"IncompatibleHub","Description":"Hub"}]})"); + QVERIFY(!hub.isEmpty()); + QVERIFY(!hub.value("isSupported").toBool()); + + // A hostile bus id in the listing is shown at most, never offered. + const QVariantMap hostile = onlyDevice(R"({"Devices":[{"BusId":"1-2 & calc","Description":"x"}]})"); + QVERIFY(!hostile.isEmpty()); + QVERIFY(!hostile.value("isSupported").toBool()); + } + + void emptyAndMalformedState() + { + QString error = QStringLiteral("stale"); + QVERIFY(parse(R"({"Devices":[]})", &error).isEmpty()); + QVERIFY(error.isEmpty()); + + for (const char *invalid : { "", "not json", "[]", "{}", R"({"Devices":42})", R"({"Devices":[{"BusId":)" }) { + error.clear(); + QVERIFY2(parse(invalid, &error).isEmpty(), invalid); + QVERIFY2(!error.isEmpty(), invalid); + } + } + + void busIdShapes_data() + { + QTest::addColumn("busId"); + QTest::addColumn("shareable"); + QTest::newRow("1-2") << QStringLiteral("1-2") << true; + QTest::newRow("12-34") << QStringLiteral("12-34") << true; + QTest::newRow("empty") << QString() << false; + QTest::newRow("hub") << QStringLiteral("IncompatibleHub") << false; + QTest::newRow("leading zero") << QStringLiteral("01-2") << false; + QTest::newRow("zero port") << QStringLiteral("1-0") << false; + QTest::newRow("dotted") << QStringLiteral("1-2.3") << false; + QTest::newRow("trailing newline") << QStringLiteral("1-2\n") << false; + QTest::newRow("extra option") << QStringLiteral("1-2 --force") << false; + QTest::newRow("quote break") << QStringLiteral("1-2\" --evil \"") << false; + QTest::newRow("shell meta") << QStringLiteral("1-2&calc") << false; + QTest::newRow("too long") << QStringLiteral("1-") + QString(40, QLatin1Char('9')) << false; + } + void busIdShapes() + { + QFETCH(QString, busId); + QFETCH(bool, shareable); + QCOMPARE(UsbForwardingBackend::isShareableBusId(busId), shareable); + // bindArguments is the only way a bus id reaches the elevated command line. + QCOMPARE(!UsbForwardingBackend::bindArguments(busId).isEmpty(), shareable); + if (shareable) { + QCOMPARE(UsbForwardingBackend::bindArguments(busId), QStringLiteral("bind --busid ") + busId); + } + } + + void unbindArgumentForms() + { + const QString guid = QStringLiteral("9b6a62f8-0123-4567-89ab-0123456789ab"); + QCOMPARE(UsbForwardingBackend::unbindArguments(QStringLiteral("1-2"), guid), + QStringLiteral("unbind --busid 1-2")); + QCOMPARE(UsbForwardingBackend::unbindArguments(QString(), guid), + QStringLiteral("unbind --guid ") + guid); + // A device that is unplugged has no usable bus id: fall back to the GUID. + QCOMPARE(UsbForwardingBackend::unbindArguments(QStringLiteral("IncompatibleHub"), guid), + QStringLiteral("unbind --guid ") + guid); + QVERIFY(UsbForwardingBackend::unbindArguments(QString(), QString()).isEmpty()); + QVERIFY(UsbForwardingBackend::unbindArguments(QStringLiteral("1-2 --force"), QString()).isEmpty()); + } + + void guidRejectsInjection_data() + { + QTest::addColumn("guid"); + QTest::addColumn("valid"); + QTest::newRow("good") << QStringLiteral("9b6a62f8-0123-4567-89ab-0123456789ab") << true; + QTest::newRow("upper") << QStringLiteral("9B6A62F8-0123-4567-89AB-0123456789AB") << true; + QTest::newRow("braces") << QStringLiteral("{9b6a62f8-0123-4567-89ab-0123456789ab}") << false; + QTest::newRow("trailing option") << QStringLiteral("9b6a62f8-0123-4567-89ab-0123456789ab --all") << false; + QTest::newRow("quote") << QStringLiteral("9b6a62f8-0123-4567-89ab-0123456789ab\"") << false; + QTest::newRow("short") << QStringLiteral("9b6a62f8-0123-4567-89ab-0123456789a") << false; + QTest::newRow("non hex") << QStringLiteral("zb6a62f8-0123-4567-89ab-0123456789ab") << false; + QTest::newRow("newline") << QStringLiteral("9b6a62f8-0123-4567-89ab-0123456789ab\n") << false; + QTest::newRow("empty") << QString() << false; + } + void guidRejectsInjection() + { + QFETCH(QString, guid); + QFETCH(bool, valid); + QCOMPARE(UsbForwardingBackend::isValidPersistedGuid(guid), valid); + QCOMPARE(!UsbForwardingBackend::unbindArguments(QString(), guid).isEmpty(), valid); + } + + void invalidBusIdIsRefusedBeforeAnyElevation() + { + // Safe to call: an invalid id returns before locating or launching anything. + UsbForwardingBackend *backend = UsbForwardingBackend::get(); + QSignalSpy spy(backend, &UsbForwardingBackend::operationFinished); + backend->bind(QStringLiteral("1-2 & calc")); + backend->unbind(QStringLiteral("x\" --evil"), QStringLiteral("not-a-guid")); + QCOMPARE(spy.count(), 2); + QVERIFY(!spy.at(0).at(0).toBool()); + QVERIFY(!spy.at(1).at(0).toBool()); + } +}; + +QTEST_MAIN(BackendListTest) +#include "main.moc" diff --git a/tests/usb_forwarding_backend_list/usb_forwarding_backend_list.pro b/tests/usb_forwarding_backend_list/usb_forwarding_backend_list.pro new file mode 100644 index 0000000000..86c0ebddaa --- /dev/null +++ b/tests/usb_forwarding_backend_list/usb_forwarding_backend_list.pro @@ -0,0 +1,14 @@ +QT += core testlib +QT -= gui +CONFIG += c++17 console testcase +CONFIG -= app_bundle +TEMPLATE = app +TARGET = usb_forwarding_backend_list_test +SOURCES += main.cpp \ + ../../app/backend/usbforwardingbackend.cpp \ + ../../app/backend/usbforwardingenvironment.cpp +HEADERS += \ + ../../app/backend/usbforwardingbackend.h \ + ../../app/backend/usbforwardingcapability.h \ + ../../app/backend/usbforwardingenvironment.h +win32:LIBS += -ladvapi32 -lshell32 diff --git a/tests/usb_forwarding_capability/main.cpp b/tests/usb_forwarding_capability/main.cpp new file mode 100644 index 0000000000..b5e43ddbf0 --- /dev/null +++ b/tests/usb_forwarding_capability/main.cpp @@ -0,0 +1,130 @@ +// Capability JSON parsing and the shared bus id rule. +// Derived from qiin2333/moonlight-qt (GPL-3.0). +#include "../../app/backend/usbforwardingcapability.h" + +#include + +using UsbForwarding::Capability; + +class CapabilityTest : public QObject +{ + Q_OBJECT + + static QByteArray token() { return QByteArray(64, 'a'); } + static QByteArray ready(const QByteArray &port = "47996") + { + return "{\"version\":1,\"enabled\":true,\"available\":true,\"reason\":\"ready\",\"port\":" + + port + ",\"token\":\"" + token() + "\"}"; + } + +private slots: + void readyCarriesPortAndToken() + { + const auto capability = Capability::parse(ready()); + QVERIFY(capability.has_value()); + QVERIFY(capability->available); + QCOMPARE(capability->port, quint16(47996)); + QCOMPARE(capability->token, token()); + } + + void disabledAndUnavailableCarryNoCredentials() + { + for (const char *enabled : { "true", "false" }) { + const auto disabled = Capability::parse( + QByteArray("{\"version\":1,\"enabled\":") + enabled + ",\"available\":false}"); + QVERIFY(disabled.has_value()); + QVERIFY(!disabled->available); + QVERIFY(disabled->token.isEmpty()); + QCOMPARE(disabled->port, quint16(0)); + } + // enabled but not available must never be treated as ready, even + // if a token is present. + const auto stray = Capability::parse( + "{\"version\":1,\"enabled\":false,\"available\":true,\"port\":5,\"token\":\"" + token() + "\"}"); + QVERIFY(stray.has_value()); + QVERIFY(!stray->available); + QVERIFY(stray->token.isEmpty()); + } + + void portRangeIsValidated_data() + { + QTest::addColumn("port"); + QTest::addColumn("accepted"); + QTest::newRow("1") << QByteArray("1") << true; + QTest::newRow("65535") << QByteArray("65535") << true; + QTest::newRow("0") << QByteArray("0") << false; + QTest::newRow("-1") << QByteArray("-1") << false; + QTest::newRow("65536") << QByteArray("65536") << false; + QTest::newRow("fraction") << QByteArray("47996.5") << false; + QTest::newRow("string") << QByteArray("\"47996\"") << false; + QTest::newRow("huge") << QByteArray("1e30") << false; + QTest::newRow("null") << QByteArray("null") << false; + } + void portRangeIsValidated() + { + QFETCH(QByteArray, port); + QFETCH(bool, accepted); + QCOMPARE(Capability::parse(ready(port)).has_value(), accepted); + } + + void malformedResponsesAreRejected_data() + { + QTest::addColumn("body"); + QTest::newRow("empty object") << QByteArray("{}"); + QTest::newRow("not json") << QByteArray("hello"); + QTest::newRow("array") << QByteArray("[]"); + QTest::newRow("short token") << QByteArray(ready()).replace(token(), ""); + QTest::newRow("non hex token") << QByteArray(ready()).replace(token(), QByteArray(64, 'z')); + QTest::newRow("long token") << QByteArray(ready()).replace(token(), QByteArray(65, 'a')); + QTest::newRow("version 2") << QByteArray(ready()).replace("\"version\":1", "\"version\":2"); + QTest::newRow("version string") << QByteArray(ready()).replace("\"version\":1", "\"version\":\"1\""); + QTest::newRow("enabled string") << QByteArray(ready()).replace("\"enabled\":true", "\"enabled\":\"true\""); + } + void malformedResponsesAreRejected() + { + QFETCH(QByteArray, body); + QVERIFY(!Capability::parse(body).has_value()); + } + + void responseSizeCapIsExactly4096() + { + QByteArray padded = ready(); + padded.append(QByteArray(UsbForwarding::kMaxHandshakeBytes - padded.size(), ' ')); + QCOMPARE(padded.size(), qsizetype(4096)); + QVERIFY(Capability::parse(padded).has_value()); + QVERIFY(!Capability::parse(padded + ' ').has_value()); + } + + void busIdRule_data() + { + QTest::addColumn("busId"); + QTest::addColumn("accepted"); + QTest::newRow("plain") << QByteArray("1-2") << true; + QTest::newRow("hub path") << QByteArray("3-1.2.4") << true; + QTest::newRow("colon") << QByteArray("usb:1") << true; + QTest::newRow("letters") << QByteArray("AbZ-09") << true; + QTest::newRow("31 bytes") << QByteArray(31, 'a') << true; + QTest::newRow("32 bytes") << QByteArray(32, 'a') << false; + QTest::newRow("empty") << QByteArray() << false; + QTest::newRow("space") << QByteArray("1 2") << false; + QTest::newRow("quote") << QByteArray("1-2\"") << false; + QTest::newRow("semicolon") << QByteArray("1-2;calc") << false; + QTest::newRow("ampersand") << QByteArray("1-2&calc") << false; + QTest::newRow("percent") << QByteArray("%PATH%") << false; + QTest::newRow("newline") << QByteArray("1-2\n") << false; + QTest::newRow("nul") << QByteArray("1-2\0x", 5) << false; + QTest::newRow("slash") << QByteArray("../x") << false; + QTest::newRow("backslash") << QByteArray("1\\2") << false; + QTest::newRow("utf8") << QByteArray("\xC3\xA9") << false; + QTest::newRow("high bit") << QByteArray("1-\x80") << false; + } + void busIdRule() + { + QFETCH(QByteArray, busId); + QFETCH(bool, accepted); + QCOMPARE(UsbForwarding::isValidBusId(busId), accepted); + } +}; + +QTEST_MAIN(CapabilityTest) +#include "main.moc" diff --git a/tests/usb_forwarding_capability/usb_forwarding_capability.pro b/tests/usb_forwarding_capability/usb_forwarding_capability.pro new file mode 100644 index 0000000000..778ea966d4 --- /dev/null +++ b/tests/usb_forwarding_capability/usb_forwarding_capability.pro @@ -0,0 +1,8 @@ +QT += core testlib +QT -= gui +CONFIG += c++17 console testcase +CONFIG -= app_bundle +TEMPLATE = app +TARGET = usb_forwarding_capability_test +SOURCES += main.cpp +HEADERS += ../../app/backend/usbforwardingcapability.h diff --git a/tests/usb_forwarding_common/faketlsserver.h b/tests/usb_forwarding_common/faketlsserver.h new file mode 100644 index 0000000000..ecf3b63f8b --- /dev/null +++ b/tests/usb_forwarding_common/faketlsserver.h @@ -0,0 +1,114 @@ +#pragma once + +// Test helper: a loopback-only TLS server with a throwaway certificate, and +// a loader for the test certificates. Not part of the application. +// +// Certificates are generated by tests/run_usb_forwarding_tests.ps1 into a +// scratch directory named by the USBFWD_TEST_CERTS environment variable +// (cert-a.pem, key-a.pem, cert-b.pem, key-b.pem). Nothing is committed. + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +struct TestIdentity { + QSslCertificate certificate; + QSslKey key; + bool valid() const { return !certificate.isNull() && !key.isNull(); } +}; + +inline TestIdentity loadTestIdentity(const QString &name) +{ + const QString dir = qEnvironmentVariable("USBFWD_TEST_CERTS"); + TestIdentity identity; + if (dir.isEmpty()) { + return identity; + } + QFile certFile(dir + QStringLiteral("/cert-") + name + QStringLiteral(".pem")); + QFile keyFile(dir + QStringLiteral("/key-") + name + QStringLiteral(".pem")); + if (certFile.open(QIODevice::ReadOnly) && keyFile.open(QIODevice::ReadOnly)) { + identity.certificate = QSslCertificate(certFile.readAll(), QSsl::Pem); + identity.key = QSslKey(keyFile.readAll(), QSsl::Rsa, QSsl::Pem); + } + return identity; +} + +// Accepts TLS connections on 127.0.0.1 and reports what each peer sent. +class FakeTlsServer : public QObject +{ + Q_OBJECT +public: + explicit FakeTlsServer(const TestIdentity &identity, QObject *parent = nullptr) + : QObject(parent) + { + QSslConfiguration config = QSslConfiguration::defaultConfiguration(); + config.setLocalCertificate(identity.certificate); + config.setPrivateKey(identity.key); + config.setPeerVerifyMode(QSslSocket::QueryPeer); // ask for a client certificate, do not insist + m_Server.setSslConfiguration(config); + // Counts every TCP connection, even one whose TLS handshake later fails. + connect(&m_Server, &QSslServer::startedEncryptionHandshake, this, [this] { ++m_Connections; }); + // QSslServer hands over a socket only once its handshake is complete. + connect(&m_Server, &QSslServer::pendingConnectionAvailable, this, [this] { + while (QTcpSocket *tcp = m_Server.nextPendingConnection()) { + auto *socket = qobject_cast(tcp); + if (!socket) { + tcp->deleteLater(); + continue; + } + m_Sockets.append(socket); + const auto markEncrypted = [this, socket] { + ++m_Encrypted; + m_LastPeerCertificate = socket->peerCertificate(); + if (onEncrypted) { + onEncrypted(socket); + } + }; + if (socket->isEncrypted()) { + markEncrypted(); + } else { + connect(socket, &QSslSocket::encrypted, this, markEncrypted); + } + const auto readAvailable = [this, socket] { + const QByteArray chunk = socket->readAll(); + if (chunk.isEmpty()) { + return; + } + m_Received += chunk; + if (onData) { + onData(socket, m_Received); + } + }; + connect(socket, &QSslSocket::readyRead, this, readAvailable); + readAvailable(); + } + }); + } + + bool listen() { return m_Server.listen(QHostAddress::LocalHost, 0); } + quint16 port() const { return m_Server.serverPort(); } + int connections() const { return m_Connections; } + int encryptedConnections() const { return m_Encrypted; } + // Every application byte any client sent us over TLS. + QByteArray received() const { return m_Received; } + // Client certificate presented by the most recent encrypted connection. + QSslCertificate lastPeerCertificate() const { return m_LastPeerCertificate; } + QSslSocket *lastSocket() const { return m_Sockets.isEmpty() ? nullptr : m_Sockets.last(); } + + std::function onEncrypted; + std::function onData; + +private: + QSslServer m_Server; + QList m_Sockets; + QByteArray m_Received; + QSslCertificate m_LastPeerCertificate; + int m_Connections = 0; + int m_Encrypted = 0; +}; diff --git a/tests/usb_forwarding_environment/main.cpp b/tests/usb_forwarding_environment/main.cpp new file mode 100644 index 0000000000..47167136c9 --- /dev/null +++ b/tests/usb_forwarding_environment/main.cpp @@ -0,0 +1,344 @@ +// How the client decides which usbipd.exe to trust (and run elevated). +// Windows only; the sharing stack itself is never started or touched. +// Derived from qiin2333/moonlight-qt (GPL-3.0). +#include "../../app/backend/usbforwardingenvironment.h" + +#include +#include +#include +#include +#include +#include + +using Env = UsbForwardingEnvironment; + +class EnvironmentTest : public QObject +{ + Q_OBJECT + + static bool writeFakeExe(const QString &path) + { + QDir().mkpath(QFileInfo(path).absolutePath()); + QFile file(path); + if (!file.open(QIODevice::WriteOnly)) { + return false; + } + file.write("MZ not a real program"); + return true; + } + +private slots: + void readinessErrors() + { + QVERIFY(Env::readinessError(Env::Ready).isEmpty()); + for (auto state : { Env::Checking, Env::NotInstalled, Env::ServiceStopped, + Env::DriverStopped, Env::CheckFailed, Env::Unsupported }) { + QVERIFY2(!Env::readinessError(state).isEmpty(), "every non-ready state explains itself"); + } + QVERIFY(Env::readinessError(Env::DriverStopped) != Env::readinessError(Env::ServiceStopped)); + QVERIFY(!Env::readinessError(Env::Unsupported).contains(QLatin1String("Sunshine"))); + } + + void serviceImagePathParsing_data() + { + QTest::addColumn("imagePath"); + QTest::addColumn("expected"); + const QString ok = QStringLiteral("C:/Program Files/usbipd-win/usbipd.exe"); + QTest::newRow("quoted with args") << QStringLiteral("\"C:\\Program Files\\usbipd-win\\usbipd.exe\" server") << ok; + QTest::newRow("quoted bare") << QStringLiteral("\"C:\\Program Files\\usbipd-win\\usbipd.exe\"") << ok; + QTest::newRow("unquoted with spaces") << QStringLiteral("C:\\Program Files\\usbipd-win\\usbipd.exe server") << ok; + QTest::newRow("unquoted bare") << QStringLiteral("C:\\Program Files\\usbipd-win\\usbipd.exe") << ok; + QTest::newRow("forward slashes") << QStringLiteral("\"C:/Program Files/usbipd-win/usbipd.exe\"") << ok; + QTest::newRow("padded") << QStringLiteral(" \"C:\\Program Files\\usbipd-win\\usbipd.exe\" ") << ok; + QTest::newRow("empty") << QString() << QString(); + QTest::newRow("bare name") << QStringLiteral("usbipd.exe server") << QString(); + QTest::newRow("relative") << QStringLiteral("\"tools\\usbipd.exe\"") << QString(); + QTest::newRow("drive relative") << QStringLiteral("\"C:usbipd.exe\"") << QString(); + QTest::newRow("unc") << QStringLiteral("\"\\\\server\\share\\usbipd.exe\"") << QString(); + QTest::newRow("device path") << QStringLiteral("\"\\\\?\\C:\\x\\usbipd.exe\"") << QString(); + QTest::newRow("env variable") << QStringLiteral("\"%ProgramFiles%\\usbipd-win\\usbipd.exe\"") << QString(); + QTest::newRow("unterminated quote") << QStringLiteral("\"C:\\Program Files\\usbipd.exe") << QString(); + QTest::newRow("no exe") << QStringLiteral("C:\\Windows\\system32\\svchost.dll -k x") << QString(); + QTest::newRow("exe not word end") << QStringLiteral("C:\\x\\usbipd.exetra.dll") << QString(); + } + void serviceImagePathParsing() + { + QFETCH(QString, imagePath); + QFETCH(QString, expected); + QCOMPARE(Env::parseServiceBinaryPath(imagePath), expected); + } + + void trustRejectsBadPaths_data() + { + QTest::addColumn("path"); + QTest::addColumn("reasonPart"); + QTest::newRow("empty") << QString() << QStringLiteral("empty"); + QTest::newRow("bare name") << QStringLiteral("usbipd.exe") << QStringLiteral("absolute"); + QTest::newRow("dot relative") << QStringLiteral("./usbipd.exe") << QStringLiteral("absolute"); + QTest::newRow("unc") << QStringLiteral("//server/share/usbipd.exe") << QStringLiteral("absolute"); + QTest::newRow("parent hop") << QStringLiteral("C:/Windows/System32/../System32/cmd.exe") + << QStringLiteral("parent"); + QTest::newRow("wrong name") << QStringLiteral("C:/Windows/System32/cmd.exe") + << QStringLiteral("expected one"); + QTest::newRow("env variable") << QStringLiteral("%ProgramFiles%/usbipd-win/usbipd.exe") + << QStringLiteral("unsafe"); + QTest::newRow("missing file") << QStringLiteral("C:/Program Files/no-such-dir-usbfwd/usbipd.exe") + << QStringLiteral("does not exist"); + } + void trustRejectsBadPaths() + { + QFETCH(QString, path); + QFETCH(QString, reasonPart); + QString reason; + QVERIFY2(!Env::isTrustedUsbipdPath(path, &reason), qPrintable(path)); + QVERIFY2(reason.contains(reasonPart), qPrintable(reason)); + } + + void trustRejectsUserWritableLocation() + { + // A perfectly named file in a directory the current user owns: this is + // what a planted usbipd.exe looks like. + QTemporaryDir dir; + QVERIFY(dir.isValid()); + const QString fake = dir.filePath(QStringLiteral("usbipd.exe")); + QVERIFY(writeFakeExe(fake)); + QString reason; + QVERIFY(!Env::isTrustedUsbipdPath(fake, &reason)); + QVERIFY2(reason.contains(QLatin1String("writable")), qPrintable(reason)); + QVERIFY(Env::isWritableByStandardUsers(fake)); + } + + void writabilityCheck() + { + QVERIFY(!Env::isWritableByStandardUsers(QStringLiteral("C:/Windows/System32/cmd.exe"))); + QVERIFY(!Env::isWritableByStandardUsers(QStringLiteral("C:/Program Files"))); + // Fails closed: a path we cannot inspect counts as writable. + QVERIFY(Env::isWritableByStandardUsers(QStringLiteral("C:/does/not/exist/usbipd.exe"))); + QTemporaryDir dir; + QVERIFY(dir.isValid()); + QVERIFY(Env::isWritableByStandardUsers(dir.path())); + } + + void installedUsbipdIsAcceptedWhenPresent() + { + const QString installed = QStringLiteral("C:/Program Files/usbipd-win/usbipd.exe"); + if (!QFileInfo::exists(installed)) { + QSKIP("usbipd-win is not installed here; the positive case cannot be exercised"); + } + QString reason; + QVERIFY2(Env::isTrustedUsbipdPath(installed, &reason), qPrintable(reason)); + QCOMPARE(Env::locateUsbipd().toLower(), installed.toLower()); + } + + void candidatesNeverComeFromPathCwdOrEnvironment() + { + // Plant files where they WOULD pass every check (the predicate below + // trusts everything), first on PATH, as the cwd, and via the Program + // Files variables. The lookup must not even list them. + QTemporaryDir planted; + QVERIFY(planted.isValid()); + QVERIFY(writeFakeExe(planted.filePath(QStringLiteral("usbipd.exe")))); + QVERIFY(writeFakeExe(planted.filePath(QStringLiteral("usbipd-win/usbipd.exe")))); + const QString plantedDir = QDir::fromNativeSeparators(planted.path()); + + const QByteArray oldPath = qgetenv("PATH"); + const QByteArray oldPf = qgetenv("ProgramFiles"); + const QByteArray oldPf6432 = qgetenv("ProgramW6432"); + const QByteArray oldPfx86 = qgetenv("ProgramFiles(x86)"); + const QString oldCwd = QDir::currentPath(); + const QByteArray native = QDir::toNativeSeparators(planted.path()).toLocal8Bit(); + qputenv("PATH", native + ";" + oldPath); + qputenv("ProgramFiles", native); + qputenv("ProgramW6432", native); + qputenv("ProgramFiles(x86)", native); + QVERIFY(QDir::setCurrent(planted.path())); + + const QStringList candidates = Env::candidateUsbipdPaths(); + const QString chosen = Env::firstAccepted(candidates, [](const QString &) { return true; }); + + QDir::setCurrent(oldCwd); + qputenv("PATH", oldPath); + qputenv("ProgramFiles", oldPf); + qputenv("ProgramW6432", oldPf6432); + qputenv("ProgramFiles(x86)", oldPfx86); + + for (const QString &candidate : candidates) { + QVERIFY2(!QDir::fromNativeSeparators(candidate).startsWith(plantedDir, Qt::CaseInsensitive), + qPrintable("planted path became a candidate: " + candidate)); + QVERIFY2(QFileInfo(candidate).fileName().compare(QStringLiteral("usbipd.exe"), Qt::CaseInsensitive) == 0, + qPrintable(candidate)); + } + QVERIFY2(!QDir::fromNativeSeparators(chosen).startsWith(plantedDir, Qt::CaseInsensitive), + qPrintable("planted usbipd.exe was chosen: " + chosen)); + // The seam itself works: with a predicate that accepts, the first + // candidate (if there is one) is returned. + QCOMPARE(Env::firstAccepted({ QStringLiteral("C:/a/usbipd.exe"), QStringLiteral("C:/b/usbipd.exe") }, + [](const QString &) { return true; }), + QStringLiteral("C:/a/usbipd.exe")); + QCOMPARE(Env::firstAccepted({ QStringLiteral("C:/a/usbipd.exe"), QStringLiteral("C:/b/usbipd.exe") }, + [](const QString &p) { return p.contains(QLatin1String("/b/")); }), + QStringLiteral("C:/b/usbipd.exe")); + } + + void standardUserTokenJudgesRealSystemFiles() + { + // Owned and protected by the system: a normal user cannot change them. + QVERIFY(!Env::isWritableByStandardUsers(QStringLiteral("C:/Windows/System32/notepad.exe"))); + QVERIFY(!Env::isWritableByStandardUsers(QStringLiteral("C:/Windows/System32"))); + // Writable through a group grant rather than ownership. + QVERIFY(Env::isWritableByStandardUsers(QStringLiteral("C:/Windows/Temp"))); + // A file the current user just created. + QTemporaryDir dir; + QVERIFY(dir.isValid()); + const QString mine = dir.filePath(QStringLiteral("mine.exe")); + QVERIFY(writeFakeExe(mine)); + QVERIFY(Env::isWritableByStandardUsers(mine)); + QVERIFY(Env::isWritableByStandardUsers(dir.path())); + // The drive root is judged too: adding folders there is normal and + // must not condemn everything below it. + QVERIFY(!Env::isWritableByStandardUsers(QStringLiteral("C:/Windows/System32/cmd.exe"))); + } + + void realSystemFilePassesEveryCheckExceptTheName() + { + // The positive case, run against a real protected file because + // usbipd-win is not installed here. + const QString notepad = QStringLiteral("C:/Windows/System32/notepad.exe"); + QString reason; + QVERIFY2(Env::isTrustedExecutablePath(notepad, QStringLiteral("notepad.exe"), &reason), qPrintable(reason)); + QVERIFY(!Env::isTrustedExecutablePath(notepad, QStringLiteral("usbipd.exe"), &reason)); + // A file of the right name the current user can change is refused. + QTemporaryDir dir; + QVERIFY(dir.isValid()); + const QString mine = dir.filePath(QStringLiteral("notepad.exe")); + QVERIFY(writeFakeExe(mine)); + QVERIFY(!Env::isTrustedExecutablePath(mine, QStringLiteral("notepad.exe"), &reason)); + QVERIFY2(reason.contains(QLatin1String("writable")), qPrintable(reason)); + } + + void adminOnlyLocationIsTrustedUntilAGroupGetsWriteAccess() + { + // The real positive case: a usbipd.exe in a directory only SYSTEM and + // Administrators can change. It lives directly under the drive root so + // that no user-writable ancestor (like %TEMP%) hides the result. Needs + // an elevated test run to create it there; otherwise it is skipped. + const QString target = QStringLiteral("C:/usbfwd-acltest-") + QString::number(QCoreApplication::applicationPid()); + struct Cleanup { + QString dir; + ~Cleanup() { QDir(dir).removeRecursively(); } + } cleanup { target }; + if (!QDir().mkpath(target)) { + QSKIP("cannot create a directory under the drive root"); + } + const QString file = target + QStringLiteral("/usbipd.exe"); + const QString native = QDir::toNativeSeparators(target); + if (QProcess::execute(QStringLiteral("icacls.exe"), + { native, QStringLiteral("/inheritance:r"), QStringLiteral("/grant:r"), + QStringLiteral("*S-1-5-18:(OI)(CI)F"), QStringLiteral("*S-1-5-32-544:(OI)(CI)F"), + QStringLiteral("*S-1-5-32-545:(OI)(CI)RX"), QStringLiteral("*S-1-1-0:(OI)(CI)RX"), QStringLiteral("*S-1-5-11:(OI)(CI)RX"), + qEnvironmentVariable("USERDOMAIN") + QLatin1Char(0x5C) + qEnvironmentVariable("USERNAME") + QStringLiteral(":(OI)(CI)RX"), + QStringLiteral("/C"), QStringLiteral("/Q") }) != 0) { + QSKIP("icacls could not restrict the test directory"); + } + // Create the file only now, so it inherits the restricted ACL. Doing it + // first and then running "icacls /inheritance:r /T" strips the file's + // inherited entries and leaves it with an empty ACL nobody can open. + QVERIFY(writeFakeExe(file)); + if (Env::isWritableByStandardUsers(file)) { + // The current user owns the file (a non-elevated account), so ownership + // alone grants control and the group case cannot be isolated here. + QSKIP("the test file is owned by the current user; run this test elevated"); + } + QString reason; + QVERIFY2(Env::isTrustedUsbipdPath(file, &reason), qPrintable(reason)); + + // "This Organization" (S-1-5-15) is in the token but in no fixed list. + QVERIFY(QProcess::execute(QStringLiteral("icacls.exe"), + { native, QStringLiteral("/grant"), QStringLiteral("*S-1-5-15:(OI)(CI)M"), + QStringLiteral("/T"), QStringLiteral("/C"), QStringLiteral("/Q") }) == 0); + QVERIFY2(Env::isWritableByStandardUsers(file), "group grant was not noticed"); + QVERIFY(!Env::isTrustedUsbipdPath(file, &reason)); + QVERIFY2(reason.contains(QLatin1String("writable")), qPrintable(reason)); + } + + void linkedPathIsRefused() + { + QTemporaryDir dir; + QVERIFY(dir.isValid()); + const QString real = dir.filePath(QStringLiteral("real")); + QVERIFY(writeFakeExe(real + QStringLiteral("/usbipd.exe"))); + const QString junction = dir.filePath(QStringLiteral("link")); + if (QProcess::execute(QStringLiteral("cmd.exe"), + { QStringLiteral("/c"), QStringLiteral("mklink"), QStringLiteral("/J"), + QDir::toNativeSeparators(junction), QDir::toNativeSeparators(real) }) != 0) { + QSKIP("could not create a junction"); + } + QString reason; + const bool trusted = Env::isTrustedUsbipdPath(junction + QStringLiteral("/usbipd.exe"), &reason); + // Remove the junction itself (not its target) before the directory is deleted. + QProcess::execute(QStringLiteral("cmd.exe"), + { QStringLiteral("/c"), QStringLiteral("rmdir"), QDir::toNativeSeparators(junction) }); + QVERIFY(!trusted); + QVERIFY2(reason.contains(QLatin1String("link")), qPrintable(reason)); + } + + void hungVersionProbeIsACheckFailureNotAStoppedService() + { + Env *env = Env::get(); + env->probeWith(QStringLiteral("C:/Windows/System32/PING.EXE"), + { QStringLiteral("-n"), QStringLiteral("30"), QStringLiteral("127.0.0.1") }, 500); + QVERIFY(env->checking()); + QTRY_VERIFY_WITH_TIMEOUT(!env->checking(), 10000); + QCOMPARE(env->state(), Env::CheckFailed); + } + + void failingVersionProbeMeansStoppedService() + { + Env *env = Env::get(); + env->probeWith(QStringLiteral("C:/Windows/System32/cmd.exe"), + { QStringLiteral("/c"), QStringLiteral("exit"), QStringLiteral("3") }, 5000); + QTRY_VERIFY_WITH_TIMEOUT(!env->checking(), 10000); + QCOMPARE(env->state(), Env::ServiceStopped); + env->probeWith(QStringLiteral("C:/no/such/dir/usbipd.exe"), {}, 5000); + QTRY_VERIFY_WITH_TIMEOUT(!env->checking(), 10000); + QCOMPARE(env->state(), Env::ServiceStopped); + } + + void locateNeverConsultsPathCwdOrEnvironment() + { + QTemporaryDir planted; + QVERIFY(planted.isValid()); + const QString fake = planted.filePath(QStringLiteral("usbipd.exe")); + const QString fakeInProgramFiles = planted.filePath(QStringLiteral("usbipd-win/usbipd.exe")); + QVERIFY(writeFakeExe(fake)); + QVERIFY(writeFakeExe(fakeInProgramFiles)); + + // Plant it first on PATH, make it the working directory, and point the + // Program Files variables at the same directory. + const QByteArray oldPath = qgetenv("PATH"); + const QByteArray oldPf = qgetenv("ProgramFiles"); + const QByteArray oldPf6432 = qgetenv("ProgramW6432"); + const QString oldCwd = QDir::currentPath(); + qputenv("PATH", QDir::toNativeSeparators(planted.path()).toLocal8Bit() + ";" + oldPath); + qputenv("ProgramFiles", QDir::toNativeSeparators(planted.path()).toLocal8Bit()); + qputenv("ProgramW6432", QDir::toNativeSeparators(planted.path()).toLocal8Bit()); + QVERIFY(QDir::setCurrent(planted.path())); + + const QString located = Env::locateUsbipd(); + + QDir::setCurrent(oldCwd); + qputenv("PATH", oldPath); + qputenv("ProgramFiles", oldPf); + qputenv("ProgramW6432", oldPf6432); + + QVERIFY2(!located.startsWith(QDir::fromNativeSeparators(planted.path()), Qt::CaseInsensitive), + qPrintable("planted usbipd.exe was chosen: " + located)); + if (!located.isEmpty()) { + QString reason; + QVERIFY2(Env::isTrustedUsbipdPath(located, &reason), qPrintable(reason)); + } + } +}; + +QTEST_MAIN(EnvironmentTest) +#include "main.moc" diff --git a/tests/usb_forwarding_environment/usb_forwarding_environment.pro b/tests/usb_forwarding_environment/usb_forwarding_environment.pro new file mode 100644 index 0000000000..270e94b184 --- /dev/null +++ b/tests/usb_forwarding_environment/usb_forwarding_environment.pro @@ -0,0 +1,9 @@ +QT += core testlib +QT -= gui +CONFIG += c++17 console testcase +CONFIG -= app_bundle +TEMPLATE = app +TARGET = usb_forwarding_environment_test +SOURCES += main.cpp ../../app/backend/usbforwardingenvironment.cpp +HEADERS += ../../app/backend/usbforwardingenvironment.h +win32:LIBS += -ladvapi32 diff --git a/tests/usb_forwarding_nvhttp/main.cpp b/tests/usb_forwarding_nvhttp/main.cpp new file mode 100644 index 0000000000..b4fd33b2fa --- /dev/null +++ b/tests/usb_forwarding_nvhttp/main.cpp @@ -0,0 +1,237 @@ +// NvHTTP::getUsbForwardingCapability against fake HTTPS hosts on loopback: +// pin, size cap, redirects, status handling. The real nvhttp.cpp is compiled +// (a copy, next to stubs for its collaborators; see the .pro). +// Derived from qiin2333/moonlight-qt (GPL-3.0). +#include "nvhttp.h" +#include "../usb_forwarding_common/faketlsserver.h" + +#include +#include + +namespace { +QStringList g_LogMessages; +void captureMessages(QtMsgType, const QMessageLogContext &, const QString &message) +{ + g_LogMessages.append(message); +} +const QByteArray kToken(64, 'd'); + +QByteArray readyJson() +{ + return "{\"version\":1,\"enabled\":true,\"available\":true,\"reason\":\"ready\",\"port\":47996,\"token\":\"" + + kToken + "\"}"; +} + +QByteArray http(int status, const QByteArray &reason, const QByteArray &body, const QByteArray &extra = QByteArray()) +{ + return "HTTP/1.1 " + QByteArray::number(status) + " " + reason + + "\r\nContent-Type: application/json\r\nContent-Length: " + QByteArray::number(body.size()) + + "\r\nConnection: close\r\n" + extra + "\r\n" + body; +} + +// Runs fn and returns the exception text, or null if nothing was thrown. +QString thrown(const std::function &fn) +{ + try { + fn(); + } catch (const std::exception &e) { + return QString::fromUtf8(e.what()); + } + return QString(); +} + +// Answers every request with one canned response, then closes. +void serveCanned(FakeTlsServer &server, const QByteArray &response, QByteArray *firstRequestLine = nullptr) +{ + server.onData = [response, firstRequestLine](QSslSocket *socket, const QByteArray &all) { + if (!all.contains("\r\n\r\n")) { + return; + } + if (firstRequestLine) { + *firstRequestLine = all.left(all.indexOf("\r\n")); + } + socket->write(response); + socket->disconnectFromHost(); + }; +} +} // namespace + +class NvHttpCapabilityTest : public QObject +{ + Q_OBJECT + + TestIdentity a, b; + + // Fresh NvHTTP pinned to certificate A and aimed at the fake host. + std::unique_ptr client(quint16 port, const TestIdentity &pinned) + { + return std::make_unique(NvAddress(QStringLiteral("127.0.0.1"), 47989), port, + pinned.certificate, false); + } + +private slots: + void initTestCase() + { + a = loadTestIdentity(QStringLiteral("a")); + b = loadTestIdentity(QStringLiteral("b")); + QVERIFY2(a.valid() && b.valid(), + "test certificates missing: run tests/run_usb_forwarding_tests.ps1"); + qInstallMessageHandler(captureMessages); + } + + void init() { g_LogMessages.clear(); } + + void cleanup() + { + for (const QString &line : std::as_const(g_LogMessages)) { + QVERIFY2(!line.contains(QString::fromLatin1(kToken)), "token was logged"); + } + } + + void readyReplyYieldsPortAndToken() + { + FakeTlsServer host(a); + QVERIFY(host.listen()); + QByteArray requestLine; + serveCanned(host, http(200, "OK", readyJson()), &requestLine); + auto nv = client(host.port(), a); + + UsbForwarding::Capability capability; + QVERIFY2(thrown([&] { capability = nv->getUsbForwardingCapability(); }).isNull(), "unexpected exception"); + QVERIFY(capability.available); + QCOMPARE(capability.port, quint16(47996)); + QCOMPARE(capability.token, kToken); + QVERIFY2(requestLine.startsWith("GET /api/v1/usb-forwarding"), requestLine.constData()); + } + + void disabledReplyIsNotAnError() + { + FakeTlsServer host(a); + QVERIFY(host.listen()); + serveCanned(host, http(200, "OK", + "{\"version\":1,\"enabled\":false,\"available\":false,\"reason\":\"disabled\"}")); + auto nv = client(host.port(), a); + UsbForwarding::Capability capability; + QVERIFY(thrown([&] { capability = nv->getUsbForwardingCapability(); }).isNull()); + QVERIFY(!capability.available); + QCOMPARE(capability.reason, QStringLiteral("disabled")); + } + + void responseCapIsExactly4096Bytes() + { + QByteArray atCap = readyJson(); + atCap.append(QByteArray(4096 - atCap.size(), ' ')); + { + FakeTlsServer host(a); + QVERIFY(host.listen()); + serveCanned(host, http(200, "OK", atCap)); + auto nv = client(host.port(), a); + QVERIFY2(thrown([&] { nv->getUsbForwardingCapability(); }).isNull(), "4096 bytes must be accepted"); + } + { + FakeTlsServer host(a); + QVERIFY(host.listen()); + serveCanned(host, http(200, "OK", atCap + " ")); + auto nv = client(host.port(), a); + const QString message = thrown([&] { nv->getUsbForwardingCapability(); }); + QVERIFY2(message.contains(QStringLiteral("too large")), qPrintable(message)); + } + } + + void hugeResponseIsCutOff() + { + FakeTlsServer host(a); + QVERIFY(host.listen()); + serveCanned(host, http(200, "OK", QByteArray(3 * 1024 * 1024, 'z'))); + auto nv = client(host.port(), a); + const QString message = thrown([&] { nv->getUsbForwardingCapability(); }); + QVERIFY2(message.contains(QStringLiteral("too large")), qPrintable(message)); + } + + void redirectsAreNeverFollowed() + { + // The redirect target presents the same trusted certificate, so a + // client that followed redirects would be served by it. + FakeTlsServer target(a); + QVERIFY(target.listen()); + serveCanned(target, http(200, "OK", readyJson())); + + FakeTlsServer host(a); + QVERIFY(host.listen()); + serveCanned(host, http(302, "Found", QByteArray(), + "Location: https://127.0.0.1:" + QByteArray::number(target.port()) + "/steal\r\n")); + auto nv = client(host.port(), a); + + UsbForwarding::Capability capability; + const QString message = thrown([&] { capability = nv->getUsbForwardingCapability(); }); + QVERIFY2(!message.isNull(), "a redirect must be an error"); + QVERIFY2(message.contains(QStringLiteral("redirect")), qPrintable(message)); + QCOMPARE(target.connections(), 0); + QVERIFY(!capability.available); + } + + void nonOkStatusIsAnError() + { + FakeTlsServer host(a); + QVERIFY(host.listen()); + serveCanned(host, http(404, "Not Found", "{}")); + auto nv = client(host.port(), a); + QVERIFY(!thrown([&] { nv->getUsbForwardingCapability(); }).isNull()); + } + + void garbageBodyIsAnError() + { + FakeTlsServer host(a); + QVERIFY(host.listen()); + serveCanned(host, http(200, "OK", "not json")); + auto nv = client(host.port(), a); + QVERIFY(!thrown([&] { nv->getUsbForwardingCapability(); }).isNull()); + } + + void impostorCertificateGetsNoRequestAndNoCredentialsAreAccepted() + { + // Host presents B, we paired with A, and its reply carries a token. + // B is trusted by the client's CA store and matches 127.0.0.1 (see the + // identitymanager.h stub), so ordinary TLS verification accepts it and + // only the pin can refuse it. + FakeTlsServer host(b); + QVERIFY(host.listen()); + serveCanned(host, http(200, "OK", readyJson())); + auto nv = client(host.port(), a); + UsbForwarding::Capability capability; + const QString message = thrown([&] { capability = nv->getUsbForwardingCapability(); }); + QVERIFY2(!message.isNull(), "an unpinned certificate must be an error"); + QVERIFY(!capability.available); + QVERIFY(capability.token.isEmpty()); + // The request (which carries our client identity) was never sent. + QVERIFY2(host.received().isEmpty(), "request reached an unpinned host"); + } + + void unpairedHostIsRefusedWithoutConnecting() + { + FakeTlsServer host(a); + QVERIFY(host.listen()); + serveCanned(host, http(200, "OK", readyJson())); + auto nv = client(host.port(), TestIdentity()); + const QString message = thrown([&] { nv->getUsbForwardingCapability(); }); + QVERIFY2(message.contains(QStringLiteral("paired")), qPrintable(message)); + QCOMPARE(host.connections(), 0); + } + + void unboundedRequestsKeepTheirOldBehaviour() + { + // Existing callers pass no size cap, so a large reply still comes back whole. + FakeTlsServer host(a); + QVERIFY(host.listen()); + serveCanned(host, http(200, "OK", QByteArray(300000, 'q'))); + auto nv = client(host.port(), a); + QString body; + QVERIFY(thrown([&] { + body = nv->openConnectionToString(nv->m_BaseUrlHttps, QStringLiteral("serverinfo"), QString(), 5000); + }).isNull()); + QCOMPARE(body.size(), 300000); + } +}; + +QTEST_GUILESS_MAIN(NvHttpCapabilityTest) +#include "main.moc" diff --git a/tests/usb_forwarding_nvhttp/stubs/Limelight.h b/tests/usb_forwarding_nvhttp/stubs/Limelight.h new file mode 100644 index 0000000000..b1c2afd436 --- /dev/null +++ b/tests/usb_forwarding_nvhttp/stubs/Limelight.h @@ -0,0 +1,18 @@ +#pragma once +// Test stub: only what nvhttp.cpp needs to compile. Not the real library. +#include + +typedef struct _STREAM_CONFIGURATION { + int width; + int height; + int fps; + int supportedVideoFormats; + int audioConfiguration; + char remoteInputAesKey[16]; + char remoteInputAesIv[16]; +} STREAM_CONFIGURATION, *PSTREAM_CONFIGURATION; + +#define VIDEO_FORMAT_MASK_10BIT 0x2200 +#define SURROUNDAUDIOINFO_FROM_AUDIO_CONFIGURATION(x) (x) + +inline const char* LiGetLaunchUrlQueryParameters() { return ""; } diff --git a/tests/usb_forwarding_nvhttp/stubs/identitymanager.h b/tests/usb_forwarding_nvhttp/stubs/identitymanager.h new file mode 100644 index 0000000000..1c5e143fae --- /dev/null +++ b/tests/usb_forwarding_nvhttp/stubs/identitymanager.h @@ -0,0 +1,30 @@ +#pragma once +// Test stub: a client identity with no certificate. The fake hosts do not +// require one. +// +// The certificate "b" from USBFWD_TEST_CERTS (an impostor host) is installed +// as a trusted CA, so a host presenting it passes normal TLS verification +// (its certificate carries a 127.0.0.1 subjectAltName). Only the paired- +// certificate pin can refuse it, which is what the tests need to prove. +#include +#include +#include +#include + +class IdentityManager +{ +public: + QString getUniqueId() { return QStringLiteral("0123456789ABCDEF"); } + QSslConfiguration getSslConfig() + { + QSslConfiguration config = QSslConfiguration::defaultConfiguration(); + QFile file(qEnvironmentVariable("USBFWD_TEST_CERTS") + QStringLiteral("/cert-b.pem")); + if (file.open(QIODevice::ReadOnly)) { + QList authorities = config.caCertificates(); + authorities.append(QSslCertificate(file.readAll(), QSsl::Pem)); + config.setCaCertificates(authorities); + } + return config; + } + static IdentityManager* get() { static IdentityManager manager; return &manager; } +}; diff --git a/tests/usb_forwarding_nvhttp/stubs/nvcomputer.h b/tests/usb_forwarding_nvhttp/stubs/nvcomputer.h new file mode 100644 index 0000000000..835a092de0 --- /dev/null +++ b/tests/usb_forwarding_nvhttp/stubs/nvcomputer.h @@ -0,0 +1,14 @@ +#pragma once +// Test stub: the few NvComputer fields NvHTTP reads. +#include "nvaddress.h" +#include "nvhttp.h" // the real nvcomputer.h pulls it in, and nvhttp.cpp relies on that +#include + +class NvComputer +{ +public: + NvAddress activeAddress; + uint16_t activeHttpsPort = 0; + QSslCertificate serverCert; + bool isNvidiaServerSoftware = false; +}; diff --git a/tests/usb_forwarding_nvhttp/usb_forwarding_nvhttp.pro b/tests/usb_forwarding_nvhttp/usb_forwarding_nvhttp.pro new file mode 100644 index 0000000000..ea494283f8 --- /dev/null +++ b/tests/usb_forwarding_nvhttp/usb_forwarding_nvhttp.pro @@ -0,0 +1,25 @@ +# Compiles the real app/backend/nvhttp.cpp. It includes sibling headers +# (identitymanager.h, nvcomputer.h, Limelight.h) that drag in the whole +# application, so the build copies nvhttp and its small neighbours next to +# lightweight stubs in a scratch directory and compiles that copy. +QT += core network gui testlib +CONFIG += c++17 console testcase +CONFIG -= app_bundle +TEMPLATE = app +TARGET = usb_forwarding_nvhttp_test +SHADOW = $$OUT_PWD/shadow +mkpath($$SHADOW) +BACKEND = $$PWD/../../app/backend +BACKEND_FILES = nvhttp.h nvhttp.cpp nvaddress.h nvaddress.cpp nvapp.h pairedcertificate.h usbforwardingcapability.h +STUB_FILES = Limelight.h identitymanager.h nvcomputer.h +for(f, BACKEND_FILES) { + system($$QMAKE_COPY_FILE $$shell_quote($$shell_path($$BACKEND/$$f)) $$shell_quote($$shell_path($$SHADOW/$$f))) +} +for(f, STUB_FILES) { + system($$QMAKE_COPY_FILE $$shell_quote($$shell_path($$PWD/stubs/$$f)) $$shell_quote($$shell_path($$SHADOW/$$f))) +} + + +INCLUDEPATH += $$SHADOW +SOURCES += main.cpp $$SHADOW/nvhttp.cpp $$SHADOW/nvaddress.cpp +HEADERS += $$SHADOW/nvhttp.h ../usb_forwarding_common/faketlsserver.h diff --git a/tests/usb_forwarding_tunnel/main.cpp b/tests/usb_forwarding_tunnel/main.cpp new file mode 100644 index 0000000000..063e71b47d --- /dev/null +++ b/tests/usb_forwarding_tunnel/main.cpp @@ -0,0 +1,470 @@ +// The reverse USB/IP tunnel against a fake TLS "host" and a fake local +// usbipd, both on loopback. Throwaway certificates only. +// Derived from qiin2333/moonlight-qt (GPL-3.0). +#include "../../app/backend/usbforwardingtunnel.h" +#include "../usb_forwarding_common/faketlsserver.h" + +#include +#include +#include +#include +#include +#include +#include + +using UsbForwarding::Tunnel; +using UsbForwarding::TunnelConfig; + +namespace { +QStringList g_LogMessages; +void captureMessages(QtMsgType, const QMessageLogContext &, const QString &message) +{ + g_LogMessages.append(message); +} +const QByteArray kToken(64, 'c'); +} // namespace + +// Stands in for usbipd-win on the client machine. +class FakeLocalServer : public QObject +{ + Q_OBJECT +public: + FakeLocalServer() + { + connect(&m_Server, &QTcpServer::newConnection, this, [this] { + m_Socket = m_Server.nextPendingConnection(); + if (m_Paused) m_Socket->setReadBufferSize(64 * 1024); + connect(m_Socket, &QTcpSocket::readyRead, this, [this] { if (!m_Paused) m_Received += m_Socket->readAll(); }); + }); + } + bool listen() { return m_Server.listen(QHostAddress::LocalHost, 0); } + quint16 port() const { return m_Server.serverPort(); } + QByteArray received() const { return m_Received; } + QTcpSocket *socket() const { return m_Socket; } + // A paused usbipd stops reading, so TCP back-pressure builds up. + void setPaused(bool paused) + { + m_Paused = paused; + if (m_Socket) { + m_Socket->setReadBufferSize(paused ? 64 * 1024 : 0); + if (!paused) m_Received += m_Socket->readAll(); + } + } + +private: + QTcpServer m_Server; + QTcpSocket *m_Socket = nullptr; + QByteArray m_Received; + bool m_Paused = false; +}; + +// One tunnel, one fake host, one fake local server. +struct Rig +{ + explicit Rig(const TestIdentity &hostIdentity, const TestIdentity &pinned) + : host(hostIdentity) + { + QVERIFY2(host.listen(), "fake host listen"); + QVERIFY2(local.listen(), "fake local listen"); + config.host = QStringLiteral("127.0.0.1"); + config.port = host.port(); + config.sessionToken = kToken; + config.pinnedServerCertificate = pinned.certificate; + config.busId = "1-2"; + config.localPort = local.port(); + const TestIdentity client = loadTestIdentity(QStringLiteral("a")); + config.sslConfiguration.setLocalCertificate(client.certificate); + config.sslConfiguration.setPrivateKey(client.key); + // Parse the handshake line once, then follow the scenario. + host.onData = [this](QSslSocket *socket, const QByteArray &all) { + const qsizetype newline = all.indexOf('\n'); + if (newline < 0 || handshakeSeen) { + return; + } + handshakeSeen = true; + handshakeLength = newline + 1; + handshake = QJsonDocument::fromJson(all.left(newline)).object(); + if (!reply.isEmpty()) { + socket->write(reply); + } + if (closeAfterHandshake) { + socket->disconnectFromHost(); + } + }; + } + + FakeTlsServer host; + FakeLocalServer local; + TunnelConfig config; + QByteArray reply; + bool closeAfterHandshake = false; + bool handshakeSeen = false; + qsizetype handshakeLength = 0; + QJsonObject handshake; +}; + +class TunnelTest : public QObject +{ + Q_OBJECT + + TestIdentity a, b; + +private slots: + void initTestCase() + { + a = loadTestIdentity(QStringLiteral("a")); + b = loadTestIdentity(QStringLiteral("b")); + QVERIFY2(a.valid() && b.valid(), + "test certificates missing: run tests/run_usb_forwarding_tests.ps1"); + qInstallMessageHandler(captureMessages); + } + + void init() { g_LogMessages.clear(); } + + void cleanup() + { + // The token is a credential: it must not appear in any log line. + for (const QString &line : std::as_const(g_LogMessages)) { + QVERIFY2(!line.contains(QString::fromLatin1(kToken)), "token was logged"); + } + } + + void forwardsBytesBothWaysAfterHandshake() + { + Rig rig(a, a); + // The ready line and the first USB/IP bytes arrive in one write. + rig.reply = "{\"op\":\"ready\"}\nHELLO-FROM-HOST"; + Tunnel tunnel(rig.config); + QSignalSpy forwarding(&tunnel, &Tunnel::forwarding); + QSignalSpy finished(&tunnel, &Tunnel::finished); + QString error; + QVERIFY2(tunnel.start(&error), qPrintable(error)); + + QTRY_COMPARE_WITH_TIMEOUT(forwarding.count(), 1, 10000); + QCOMPARE(rig.handshake.value("op").toString(), QStringLiteral("forward")); + QCOMPARE(rig.handshake.value("token").toString().toLatin1(), kToken); + QCOMPARE(rig.handshake.value("busid").toString(), QStringLiteral("1-2")); + + // Bytes that followed the ready line reach the local server, unmodified. + QTRY_COMPARE_WITH_TIMEOUT(rig.local.received(), QByteArray("HELLO-FROM-HOST"), 10000); + + // Local -> host. + QVERIFY(rig.local.socket()); + rig.local.socket()->write("LOCAL-URB"); + QTRY_COMPARE_WITH_TIMEOUT(rig.host.received().mid(rig.handshakeLength), QByteArray("LOCAL-URB"), 10000); + + // Host -> local, after the handshake. + rig.host.lastSocket()->write("MORE"); + QTRY_COMPARE_WITH_TIMEOUT(rig.local.received(), QByteArray("HELLO-FROM-HOSTMORE"), 10000); + + tunnel.stop(); + QCOMPARE(finished.count(), 0); + QTRY_COMPARE_WITH_TIMEOUT(rig.local.socket()->state(), QAbstractSocket::UnconnectedState, 10000); + } + + void busIdIsSentVerbatimIncludingColon() + { + Rig rig(a, a); + rig.config.busId = "3-1.2:4"; + rig.reply = "{\"op\":\"ready\"}\n"; + Tunnel tunnel(rig.config); + QSignalSpy forwarding(&tunnel, &Tunnel::forwarding); + QVERIFY(tunnel.start()); + QTRY_COMPARE_WITH_TIMEOUT(forwarding.count(), 1, 10000); + QCOMPARE(rig.handshake.value("busid").toString(), QStringLiteral("3-1.2:4")); + } + + void impostorCertificateIsRefusedBeforeAnyCredentialIsSent() + { + // The host presents certificate B but we paired with A. + Rig rig(b, a); + rig.reply = "{\"op\":\"ready\"}\nSECRET-DEVICE-DATA"; + Tunnel tunnel(rig.config); + QSignalSpy forwarding(&tunnel, &Tunnel::forwarding); + QSignalSpy finished(&tunnel, &Tunnel::finished); + QVERIFY(tunnel.start()); + + QTRY_COMPARE_WITH_TIMEOUT(finished.count(), 1, 10000); + QVERIFY2(!finished.at(0).at(0).toString().isEmpty(), "failure must carry a message"); + QCOMPARE(forwarding.count(), 0); + // Not one application byte (so no token, no bus id) reached the impostor. + QVERIFY2(rig.host.received().isEmpty(), "credentials leaked to an unpinned host"); + QVERIFY(!rig.handshakeSeen); + // And nothing from the impostor reached the local USB/IP server. + QVERIFY(rig.local.received().isEmpty()); + } + + void hostErrorReplyIsReported() + { + Rig rig(a, a); + rig.reply = "{\"op\":\"error\",\"reason\":\"busy\"}\n"; + Tunnel tunnel(rig.config); + QSignalSpy forwarding(&tunnel, &Tunnel::forwarding); + QSignalSpy finished(&tunnel, &Tunnel::finished); + QVERIFY(tunnel.start()); + QTRY_COMPARE_WITH_TIMEOUT(finished.count(), 1, 10000); + QVERIFY(finished.at(0).at(0).toString().contains(QStringLiteral("already in use"))); + QCOMPARE(forwarding.count(), 0); + } + + void handshakeLineCapIs4096ContentBytes() + { + { + // 4096 content bytes + newline: within the cap, rejected only + // because it is not "ready". + Rig rig(a, a); + rig.reply = QByteArray(4096, 'x') + "\n"; + Tunnel tunnel(rig.config); + QSignalSpy finished(&tunnel, &Tunnel::finished); + QVERIFY(tunnel.start()); + QTRY_COMPARE_WITH_TIMEOUT(finished.count(), 1, 10000); + QVERIFY2(finished.at(0).at(0).toString().contains(QStringLiteral("could not start")), + qPrintable(finished.at(0).at(0).toString())); + } + { + // 4097 content bytes: over the cap. + Rig rig(a, a); + rig.reply = QByteArray(4097, 'x') + "\n"; + Tunnel tunnel(rig.config); + QSignalSpy finished(&tunnel, &Tunnel::finished); + QVERIFY(tunnel.start()); + QTRY_COMPARE_WITH_TIMEOUT(finished.count(), 1, 10000); + QVERIFY2(finished.at(0).at(0).toString().contains(QStringLiteral("invalid")), + qPrintable(finished.at(0).at(0).toString())); + } + } + + void endlessHandshakeWithoutNewlineIsCut() + { + Rig rig(a, a); + rig.reply = QByteArray(6000, 'y'); // never a newline + Tunnel tunnel(rig.config); + QSignalSpy forwarding(&tunnel, &Tunnel::forwarding); + QSignalSpy finished(&tunnel, &Tunnel::finished); + QVERIFY(tunnel.start()); + QTRY_COMPARE_WITH_TIMEOUT(finished.count(), 1, 10000); + QVERIFY(finished.at(0).at(0).toString().contains(QStringLiteral("invalid"))); + QCOMPARE(forwarding.count(), 0); + } + + void hostClosingBeforeReadyIsAFailure() + { + Rig rig(a, a); + rig.closeAfterHandshake = true; + Tunnel tunnel(rig.config); + QSignalSpy finished(&tunnel, &Tunnel::finished); + QVERIFY(tunnel.start()); + QTRY_COMPARE_WITH_TIMEOUT(finished.count(), 1, 10000); + QVERIFY(!finished.at(0).at(0).toString().isEmpty()); + } + + void hostReasonTextNeverReachesTheUserVerbatim_data() + { + QTest::addColumn("reason"); + QTest::addColumn("expectedFragment"); + QTest::newRow("html") << QByteArray("Update now") << QString(); + QTest::newRow("link") << QByteArray("see https://evil.example/pay") << QString(); + QTest::newRow("long") << QByteArray(3000, 'A') << QString(); + QTest::newRow("known code, odd case") << QByteArray(" DISABLED ") << QStringLiteral("disabled on the host"); + QTest::newRow("known unavailable") << QByteArray("unavailable") << QStringLiteral("not available"); + } + void hostReasonTextNeverReachesTheUserVerbatim() + { + QFETCH(QByteArray, reason); + QFETCH(QString, expectedFragment); + Rig rig(a, a); + QJsonObject object{ { "op", "error" }, { "reason", QString::fromLatin1(reason) } }; + rig.reply = QJsonDocument(object).toJson(QJsonDocument::Compact) + "\n"; + Tunnel tunnel(rig.config); + QSignalSpy finished(&tunnel, &Tunnel::finished); + QVERIFY(tunnel.start()); + QTRY_COMPARE_WITH_TIMEOUT(finished.count(), 1, 10000); + const QString message = finished.at(0).at(0).toString(); + QVERIFY2(!message.contains(QLatin1Char('<')) && !message.contains(QStringLiteral("http")) && + !message.contains(QStringLiteral("alert")) && message.size() < 200, + qPrintable(message)); + if (expectedFragment.isEmpty()) { + QCOMPARE(message, QStringLiteral("The host could not start USB forwarding.")); + } else { + QVERIFY2(message.contains(expectedFragment), qPrintable(message)); + } + } + + void missingClientCertificateOrKeyNeverConnects() + { + { + Rig rig(a, a); + rig.config.sslConfiguration = QSslConfiguration(); + QVERIFY(!rig.config.valid()); + Tunnel tunnel(rig.config); + QVERIFY(!tunnel.start()); + QTest::qWait(200); + QCOMPARE(rig.host.connections(), 0); + } + { + Rig rig(a, a); + rig.config.sslConfiguration.setPrivateKey(QSslKey()); + QVERIFY(!rig.config.valid()); + } + { + Rig rig(a, a); + rig.config.sslConfiguration.setLocalCertificate(QSslCertificate()); + QVERIFY(!rig.config.valid()); + } + { + Rig rig(a, a); + QVERIFY(rig.config.valid()); + rig.config.startupTimeoutMs = 0; + QVERIFY(!rig.config.valid()); + } + } + + void stopDuringHandshakeIsCleanAndSilent() + { + Rig rig(a, a); // the host reads the request but never answers + Tunnel tunnel(rig.config); + QSignalSpy forwarding(&tunnel, &Tunnel::forwarding); + QSignalSpy finished(&tunnel, &Tunnel::finished); + QVERIFY(tunnel.start()); + QTRY_VERIFY_WITH_TIMEOUT(rig.handshakeSeen, 10000); + tunnel.stop(); + tunnel.stop(); // idempotent + // The host sees the connection drop, and nothing is signalled afterwards. + QTRY_COMPARE_WITH_TIMEOUT(rig.host.lastSocket()->state(), QAbstractSocket::UnconnectedState, 10000); + QTest::qWait(300); + QCOMPARE(forwarding.count(), 0); + QCOMPARE(finished.count(), 0); + } + + void destroyingTheTunnelDuringHandshakeIsClean() + { + Rig rig(a, a); + auto *tunnel = new Tunnel(rig.config); + QSignalSpy finished(tunnel, &Tunnel::finished); + QVERIFY(tunnel->start()); + QTRY_VERIFY_WITH_TIMEOUT(rig.handshakeSeen, 10000); + delete tunnel; // joins the worker thread before returning + QTRY_COMPARE_WITH_TIMEOUT(rig.host.lastSocket()->state(), QAbstractSocket::UnconnectedState, 10000); + QTest::qWait(200); + QCOMPARE(finished.count(), 0); + } + + void stopBeforeAnyEventLoopTurnIsClean() + { + Rig rig(a, a); + Tunnel tunnel(rig.config); + QVERIFY(tunnel.start()); + tunnel.stop(); + QTest::qWait(300); + QCOMPARE(rig.host.encryptedConnections(), 0); + } + + void startupTimeoutEndsAHostThatNeverAnswers() + { + Rig rig(a, a); + rig.config.startupTimeoutMs = 800; + Tunnel tunnel(rig.config); + QSignalSpy forwarding(&tunnel, &Tunnel::forwarding); + QSignalSpy finished(&tunnel, &Tunnel::finished); + QElapsedTimer clock; + clock.start(); + QVERIFY(tunnel.start()); + QTRY_COMPARE_WITH_TIMEOUT(finished.count(), 1, 10000); + QVERIFY2(finished.at(0).at(0).toString().contains(QStringLiteral("timed out")), + qPrintable(finished.at(0).at(0).toString())); + QVERIFY2(clock.elapsed() < 5000, "the 800 ms deadline was not honoured"); + QCOMPARE(forwarding.count(), 0); + } + + void backPressureStopsReadingWhenTheLocalSideIsSlow() + { + Rig rig(a, a); + rig.reply = "{\"op\":\"ready\"}\n"; + rig.local.setPaused(true); // usbipd stops reading + Tunnel tunnel(rig.config); + QSignalSpy forwarding(&tunnel, &Tunnel::forwarding); + QVERIFY(tunnel.start()); + QTRY_COMPARE_WITH_TIMEOUT(forwarding.count(), 1, 10000); + QTRY_VERIFY_WITH_TIMEOUT(rig.local.socket() != nullptr, 10000); + + constexpr qsizetype kTotal = 48 * 1024 * 1024; + QByteArray payload(kTotal, 0); + for (qsizetype i = 0; i < kTotal; ++i) { + payload[i] = static_cast((i * 7 + i / 251) & 0xFF); + } + rig.host.lastSocket()->write(payload); + QTest::qWait(2500); + // The tunnel must not have swallowed the stream into memory: with the + // local side stalled, most of it is still queued on the host side. + // (encryptedBytesToWrite() reports the unsent ciphertext on Schannel; + // other backends report 0, so the check only applies there.) + if (QSslSocket::activeBackend() != QStringLiteral("schannel")) { + QSKIP("queue depth is only observable on the Schannel backend"); + } + const qint64 queued = rig.host.lastSocket()->bytesToWrite() + rig.host.lastSocket()->encryptedBytesToWrite(); + QVERIFY2(queued > 16 * 1024 * 1024, qPrintable(QString::number(queued))); + + rig.local.setPaused(false); + QTRY_COMPARE_WITH_TIMEOUT(rig.local.received().size(), kTotal, 60000); + QVERIFY2(rig.local.received() == payload, "bytes were lost or reordered"); + } + + void logCaptureIsLiveAndTheTokenIsNeverLogged() + { + // The tunnel itself writes no log lines. This proves the capture works + // (a canary is seen) and that the token is absent from everything the + // process logged, including the failure messages handed to the UI. + qInfo() << "canary-line"; + Rig rig(b, a); // impostor: a failure path + rig.reply = "{\"op\":\"ready\"}\n"; + Tunnel tunnel(rig.config); + QSignalSpy finished(&tunnel, &Tunnel::finished); + QVERIFY(tunnel.start()); + QTRY_COMPARE_WITH_TIMEOUT(finished.count(), 1, 10000); + QVERIFY(g_LogMessages.contains(QStringLiteral("canary-line"))); + for (const QString &line : std::as_const(g_LogMessages)) { + QVERIFY(!line.contains(QString::fromLatin1(kToken))); + } + QVERIFY(!finished.at(0).at(0).toString().contains(QString::fromLatin1(kToken))); + } + + void invalidConfigurationNeverConnects_data() + { + QTest::addColumn("busId"); + QTest::addColumn("localHost"); + QTest::addColumn("token"); + QTest::addColumn("pinned"); + QTest::newRow("busid space") << QByteArray("1 2") << QStringLiteral("127.0.0.1") << kToken << true; + QTest::newRow("busid quote") << QByteArray("1-2\"") << QStringLiteral("127.0.0.1") << kToken << true; + QTest::newRow("busid semicolon") << QByteArray("1-2;x") << QStringLiteral("127.0.0.1") << kToken << true; + QTest::newRow("busid newline") << QByteArray("1-2\n{") << QStringLiteral("127.0.0.1") << kToken << true; + QTest::newRow("busid too long") << QByteArray(32, '1') << QStringLiteral("127.0.0.1") << kToken << true; + QTest::newRow("busid empty") << QByteArray() << QStringLiteral("127.0.0.1") << kToken << true; + QTest::newRow("local host remote") << QByteArray("1-2") << QStringLiteral("192.168.1.50") << kToken << true; + QTest::newRow("local host name") << QByteArray("1-2") << QStringLiteral("example.com") << kToken << true; + QTest::newRow("no token") << QByteArray("1-2") << QStringLiteral("127.0.0.1") << QByteArray() << true; + QTest::newRow("not paired") << QByteArray("1-2") << QStringLiteral("127.0.0.1") << kToken << false; + } + void invalidConfigurationNeverConnects() + { + QFETCH(QByteArray, busId); + QFETCH(QString, localHost); + QFETCH(QByteArray, token); + QFETCH(bool, pinned); + Rig rig(a, pinned ? a : TestIdentity()); + rig.config.busId = busId; + rig.config.localHost = localHost; + rig.config.sessionToken = token; + Tunnel tunnel(rig.config); + QSignalSpy finished(&tunnel, &Tunnel::finished); + QString error; + QVERIFY(!tunnel.start(&error)); + QVERIFY(!error.isEmpty()); + QTest::qWait(300); + QCOMPARE(rig.host.connections(), 0); + QCOMPARE(finished.count(), 0); + } +}; + +QTEST_MAIN(TunnelTest) +#include "main.moc" diff --git a/tests/usb_forwarding_tunnel/usb_forwarding_tunnel.pro b/tests/usb_forwarding_tunnel/usb_forwarding_tunnel.pro new file mode 100644 index 0000000000..138dd5127b --- /dev/null +++ b/tests/usb_forwarding_tunnel/usb_forwarding_tunnel.pro @@ -0,0 +1,12 @@ +QT += core network testlib +QT -= gui +CONFIG += c++17 console testcase +CONFIG -= app_bundle +TEMPLATE = app +TARGET = usb_forwarding_tunnel_test +SOURCES += main.cpp ../../app/backend/usbforwardingtunnel.cpp +HEADERS += \ + ../../app/backend/usbforwardingtunnel.h \ + ../../app/backend/usbforwardingcapability.h \ + ../../app/backend/pairedcertificate.h \ + ../usb_forwarding_common/faketlsserver.h diff --git a/tests/usb_forwarding_tunnel_clientcert/main.cpp b/tests/usb_forwarding_tunnel_clientcert/main.cpp new file mode 100644 index 0000000000..c107747e89 --- /dev/null +++ b/tests/usb_forwarding_tunnel_clientcert/main.cpp @@ -0,0 +1,61 @@ +// The tunnel presents the paired client certificate to the host. +// Separate from usb_forwarding_tunnel because Qt's Schannel backend never asks +// a client for its certificate when acting as a server, so a fake host can +// only observe it with the OpenSSL backend (DLLs come from the runner's PATH). +// Derived from qiin2333/moonlight-qt (GPL-3.0). +#include "../../app/backend/usbforwardingtunnel.h" +#include "../usb_forwarding_common/faketlsserver.h" + +#include +#include +#include + +using UsbForwarding::Tunnel; +using UsbForwarding::TunnelConfig; + +class ClientCertTest : public QObject +{ + Q_OBJECT + +private slots: + void clientCertificateIsPresentedToTheHost() + { + QVERIFY2(QSslSocket::availableBackends().contains(QStringLiteral("openssl")), "OpenSSL backend missing"); + QCOMPARE(QSslSocket::activeBackend(), QStringLiteral("openssl")); // via QT_TLS_BACKEND + QVERIFY2(QSslSocket::supportsSsl(), "OpenSSL libraries not found on PATH"); + + const TestIdentity hostIdentity = loadTestIdentity(QStringLiteral("a")); + const TestIdentity clientIdentity = loadTestIdentity(QStringLiteral("b")); // distinct from the host's + QVERIFY(hostIdentity.valid() && clientIdentity.valid()); + + FakeTlsServer host(hostIdentity); + QVERIFY(host.listen()); + host.onData = [](QSslSocket *socket, const QByteArray &all) { + if (all.contains('\n')) { + socket->write("{\"op\":\"ready\"}\n"); + } + }; + QTcpServer local; // stands in for usbipd + QVERIFY(local.listen(QHostAddress::LocalHost, 0)); + + TunnelConfig config; + config.host = QStringLiteral("127.0.0.1"); + config.port = host.port(); + config.sessionToken = QByteArray(64, 'e'); + config.pinnedServerCertificate = hostIdentity.certificate; + config.busId = "1-2"; + config.localPort = local.serverPort(); + config.sslConfiguration.setLocalCertificate(clientIdentity.certificate); + config.sslConfiguration.setPrivateKey(clientIdentity.key); + + Tunnel tunnel(config); + QSignalSpy forwarding(&tunnel, &Tunnel::forwarding); + QVERIFY(tunnel.start()); + QTRY_COMPARE_WITH_TIMEOUT(forwarding.count(), 1, 10000); + QVERIFY2(host.lastPeerCertificate() == clientIdentity.certificate, + "the host did not receive our client certificate"); + } +}; + +QTEST_MAIN(ClientCertTest) +#include "main.moc" diff --git a/tests/usb_forwarding_tunnel_clientcert/usb_forwarding_tunnel_clientcert.pro b/tests/usb_forwarding_tunnel_clientcert/usb_forwarding_tunnel_clientcert.pro new file mode 100644 index 0000000000..d108a58358 --- /dev/null +++ b/tests/usb_forwarding_tunnel_clientcert/usb_forwarding_tunnel_clientcert.pro @@ -0,0 +1,12 @@ +QT += core network testlib +QT -= gui +CONFIG += c++17 console testcase +CONFIG -= app_bundle +TEMPLATE = app +TARGET = usb_forwarding_tunnel_clientcert_test +SOURCES += main.cpp ../../app/backend/usbforwardingtunnel.cpp +HEADERS += \ + ../../app/backend/usbforwardingtunnel.h \ + ../../app/backend/usbforwardingcapability.h \ + ../../app/backend/pairedcertificate.h \ + ../usb_forwarding_common/faketlsserver.h