#include "DeviceIdentityHelper.h" #include "ConfigHelper.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #ifdef HAVE_OPENSSL #include #include #endif namespace { QString manifestPublicKeyPath(const QString &installDir) { return QDir(installDir).filePath(QStringLiteral("config/manifest_public_key.pem")); } } // namespace DeviceIdentityHelper::DeviceIdentityHelper(const QString &installDir) : m_installDir(installDir) { } QString DeviceIdentityHelper::deviceId() const { return m_deviceId; } QString DeviceIdentityHelper::errorString() const { return m_error; } bool DeviceIdentityHelper::verifySignature(const QByteArray &payload, const QString &signatureBase64) { #ifndef HAVE_OPENSSL Q_UNUSED(payload); Q_UNUSED(signatureBase64); m_error = QCoreApplication::translate( "DeviceIdentityHelper", "OpenSSL is unavailable, so device credential signature cannot be verified."); return false; #else const QString keyPath = manifestPublicKeyPath(m_installDir); QFile keyFile(keyPath); if (!keyFile.open(QIODevice::ReadOnly)) { m_error = QCoreApplication::translate("DeviceIdentityHelper", "Device public key is missing: %1").arg(keyPath); return false; } const QByteArray keyData = keyFile.readAll(); BIO *bio = BIO_new_mem_buf(keyData.constData(), keyData.size()); EVP_PKEY *publicKey = bio ? PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr) : nullptr; if (bio) { BIO_free(bio); } if (!publicKey) { m_error = QCoreApplication::translate("DeviceIdentityHelper", "Device public key is invalid: %1").arg(keyPath); return false; } EVP_MD_CTX *ctx = EVP_MD_CTX_new(); const QByteArray signature = QByteArray::fromBase64(signatureBase64.toUtf8()); const bool ok = ctx && EVP_DigestVerifyInit(ctx, nullptr, EVP_sha256(), nullptr, publicKey) == 1 && EVP_DigestVerifyUpdate(ctx, payload.constData(), payload.size()) == 1 && EVP_DigestVerifyFinal( ctx, reinterpret_cast(signature.constData()), signature.size()) == 1; if (ctx) { EVP_MD_CTX_free(ctx); } EVP_PKEY_free(publicKey); if (!ok) { m_error = QCoreApplication::translate( "DeviceIdentityHelper", "Device credential signature is invalid. The local identity file may not match this server."); } return ok; #endif } bool DeviceIdentityHelper::loadAndVerify(const QString &expectedAppId, const QString &expectedChannel) { // client_identity.dat 是服务端签发的本机设备凭证,不是用户可手写配置。 // 本地启动时先用公钥校验签名,再校验 app/channel/license/installation/device 和有效期。 QString credentialPath = ConfigHelper::instance().clientIdentityPath(); if (!QFile::exists(credentialPath)) { credentialPath = QDir(m_installDir).filePath(QStringLiteral("config/client_identity.dat")); } QFile credentialFile(credentialPath); if (!credentialFile.open(QIODevice::ReadOnly)) { return false; } QJsonParseError parseError; const QJsonDocument wrapperDoc = QJsonDocument::fromJson(credentialFile.readAll(), &parseError); if (parseError.error != QJsonParseError::NoError || !wrapperDoc.isObject()) { m_error = QCoreApplication::translate("DeviceIdentityHelper", "Device credential file is not valid JSON: %1") .arg(credentialPath); return false; } const QJsonObject wrapper = wrapperDoc.object(); const QByteArray identityText = wrapper.value(QStringLiteral("identity_text")).toString().toUtf8(); const QString signature = wrapper.value(QStringLiteral("signature")).toString(); if (identityText.isEmpty() || !verifySignature(identityText, signature)) { return false; } const QJsonDocument identityDoc = QJsonDocument::fromJson(identityText); const QJsonObject identity = identityDoc.object(); const QDateTime expiry = QDateTime::fromString( identity.value(QStringLiteral("valid_until")).toString(), Qt::ISODate); const bool identityMatches = identity.value(QStringLiteral("app_id")).toString() == expectedAppId && identity.value(QStringLiteral("channel")).toString() == expectedChannel && !identity.value(QStringLiteral("license_id")).toString().isEmpty() && !identity.value(QStringLiteral("installation_id")).toString().isEmpty() && !identity.value(QStringLiteral("device_id")).toString().isEmpty(); if (!identityMatches) { m_error = QCoreApplication::translate( "DeviceIdentityHelper", "Device credential does not match this application, channel, license, installation or device."); return false; } if (!expiry.isValid() || expiry <= QDateTime::currentDateTimeUtc()) { m_error = QCoreApplication::translate( "DeviceIdentityHelper", "License has expired. Please ask the administrator to issue a new License."); return false; } m_deviceId = identity.value(QStringLiteral("device_id")).toString(); return true; } bool DeviceIdentityHelper::verifyLocal(const QString &appId, const QString &channel) { m_error.clear(); return loadAndVerify(appId, channel); } bool DeviceIdentityHelper::ensureIssued( const QString &apiBaseUrl, const QString &clientToken, const QString &appId, const QString &channel, const QString &licenseKey) { // 首次启动或本地凭证失效时,Launcher 会拿 License 向服务端登记设备。 // 服务端返回签名后的 identity_text,客户端保存为 client_identity.dat,并把真实 device_id 写入运行配置。 m_error.clear(); if (loadAndVerify(appId, channel)) { ConfigHelper::instance().setValue(QStringLiteral("Update"), QStringLiteral("device_id"), m_deviceId); return true; } const QString trimmedBaseUrl = apiBaseUrl.trimmed(); if (appId.trimmed().isEmpty()) { m_error = QCoreApplication::translate("DeviceIdentityHelper", "app_id is empty in app_config.json."); return false; } if (channel.trimmed().isEmpty()) { m_error = QCoreApplication::translate("DeviceIdentityHelper", "channel is empty in app_config.json."); return false; } if (trimmedBaseUrl.isEmpty() || trimmedBaseUrl.contains(QStringLiteral("YOUR_SERVER_IP"), Qt::CaseInsensitive)) { m_error = QCoreApplication::translate( "DeviceIdentityHelper", "Server address is not configured. Set config/server_config.json before building Launcher, for example: http://192.168.229.128:8000"); return false; } if (clientToken.trimmed().isEmpty()) { m_error = QCoreApplication::translate( "DeviceIdentityHelper", "client_token is empty. Copy the client_token generated by the admin page into app_config.json."); return false; } if (licenseKey.trimmed().isEmpty()) { m_error = QCoreApplication::translate( "DeviceIdentityHelper", "License is empty. Create or select a License in the admin page, then copy the generated client configuration."); return false; } ConfigHelper &config = ConfigHelper::instance(); QString installationId = config.getValue(QStringLiteral("Device"), QStringLiteral("installation_id")); if (installationId.isEmpty()) { installationId = QUuid::createUuid().toString(QUuid::WithoutBraces); if (!config.setValue(QStringLiteral("Device"), QStringLiteral("installation_id"), installationId)) { m_error = QCoreApplication::translate("DeviceIdentityHelper", "Cannot save installation id to %1: %2") .arg(config.configPath(), config.lastError()); return false; } } const QByteArray machine = QSysInfo::machineUniqueId() + installationId.toUtf8(); const QString machineHash = QString::fromLatin1( QCryptographicHash::hash(machine, QCryptographicHash::Sha256).toHex()); const QJsonObject body{ {QStringLiteral("app_id"), appId}, {QStringLiteral("channel"), channel}, {QStringLiteral("license_key"), licenseKey}, {QStringLiteral("installation_id"), installationId}, {QStringLiteral("machine_hash"), machineHash}, }; QNetworkAccessManager manager; QNetworkRequest request{QUrl(trimmedBaseUrl + QStringLiteral("/api/v1/device/issue"))}; request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json")); request.setRawHeader("X-Client-Token", clientToken.toUtf8()); QNetworkReply *reply = manager.post(request, QJsonDocument(body).toJson(QJsonDocument::Compact)); QEventLoop loop; QTimer timer; timer.setSingleShot(true); bool timeoutOk = false; int timeoutMs = ConfigHelper::instance() .getValue(QStringLiteral("Update"), QStringLiteral("request_timeout_ms")) .toInt(&timeoutOk); if (!timeoutOk || timeoutMs < 1000) { timeoutMs = 5000; } QObject::connect(&timer, &QTimer::timeout, [&]() { if (reply && reply->isRunning()) { reply->abort(); } }); QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); timer.start(timeoutMs); loop.exec(); timer.stop(); const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); const QString networkError = reply->errorString(); const QByteArray raw = reply->readAll(); reply->deleteLater(); if (status != 200) { QJsonParseError responseError; const QJsonDocument errorDoc = QJsonDocument::fromJson(raw, &responseError); QString serverMessage; if (responseError.error == QJsonParseError::NoError && errorDoc.isObject()) { const QJsonValue detail = errorDoc.object().value(QStringLiteral("detail")); serverMessage = detail.isObject() ? detail.toObject().value(QStringLiteral("msg")).toString() : detail.toString(); } if (serverMessage.isEmpty()) serverMessage = QString::fromUtf8(raw).trimmed(); if (status == 0) { m_error = QCoreApplication::translate( "DeviceIdentityHelper", "Cannot contact the update server to issue device identity.\nServer: %1\nApp: %2\nChannel: %3\nNetwork error: %4\nTimeout: %5 ms") .arg(trimmedBaseUrl, appId, channel, networkError, QString::number(timeoutMs)); } else { m_error = QCoreApplication::translate( "DeviceIdentityHelper", "Device identity request was rejected by the update server.\nServer: %1\nHTTP status: %2\nApp: %3\nChannel: %4\nServer message: %5") .arg(trimmedBaseUrl, QString::number(status), appId, channel, serverMessage.isEmpty() ? QCoreApplication::translate("DeviceIdentityHelper", "") : serverMessage); } return false; } const QJsonDocument responseDoc = QJsonDocument::fromJson(raw); const QJsonObject response = responseDoc.object(); if (response.value(QStringLiteral("identity_text")).toString().isEmpty() || response.value(QStringLiteral("signature")).toString().isEmpty()) { m_error = QCoreApplication::translate( "DeviceIdentityHelper", "Server returned an invalid device identity response."); return false; } const QJsonObject wrapper{ {QStringLiteral("identity_text"), response.value(QStringLiteral("identity_text"))}, {QStringLiteral("signature"), response.value(QStringLiteral("signature"))}, }; const QByteArray credentialBytes = QJsonDocument(wrapper).toJson(QJsonDocument::Compact); const QString credentialPath = config.clientIdentityPath(); QString writeError; if (!ConfigHelper::writeFileWithElevationIfNeeded(credentialPath, credentialBytes, &writeError)) { m_error = QCoreApplication::translate("DeviceIdentityHelper", "Cannot save device credential to %1: %2") .arg(credentialPath, writeError); return false; } if (!loadAndVerify(appId, channel)) { return false; } if (!config.setValue(QStringLiteral("Update"), QStringLiteral("device_id"), m_deviceId)) { m_error = QCoreApplication::translate("DeviceIdentityHelper", "Cannot save server device id to %1: %2") .arg(config.configPath(), config.lastError()); return false; } return true; }