2026-07-16 08:14:51 +00:00
|
|
|
#include "DeviceIdentityHelper.h"
|
|
|
|
|
#include "ConfigHelper.h"
|
|
|
|
|
|
|
|
|
|
#include <QCoreApplication>
|
|
|
|
|
#include <QCryptographicHash>
|
|
|
|
|
#include <QDateTime>
|
|
|
|
|
#include <QDir>
|
|
|
|
|
#include <QEventLoop>
|
|
|
|
|
#include <QFile>
|
|
|
|
|
#include <QJsonDocument>
|
|
|
|
|
#include <QJsonObject>
|
|
|
|
|
#include <QJsonParseError>
|
|
|
|
|
#include <QNetworkAccessManager>
|
2026-07-14 01:37:06 +00:00
|
|
|
#include <QNetworkReply>
|
|
|
|
|
#include <QNetworkRequest>
|
|
|
|
|
#include <QSysInfo>
|
|
|
|
|
#include <QTimer>
|
2026-07-16 08:14:51 +00:00
|
|
|
#include <QUuid>
|
|
|
|
|
|
|
|
|
|
#ifdef HAVE_OPENSSL
|
|
|
|
|
#include <openssl/evp.h>
|
|
|
|
|
#include <openssl/pem.h>
|
|
|
|
|
#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<const unsigned char *>(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();
|
2026-07-21 03:02:42 +00:00
|
|
|
const QString networkError = reply->errorString();
|
2026-07-16 08:14:51 +00:00
|
|
|
const QByteArray raw = reply->readAll();
|
|
|
|
|
reply->deleteLater();
|
|
|
|
|
if (status != 200) {
|
2026-07-21 03:02:42 +00:00
|
|
|
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", "<empty response>") : serverMessage);
|
|
|
|
|
}
|
2026-07-16 08:14:51 +00:00
|
|
|
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;
|
2026-07-14 01:37:06 +00:00
|
|
|
}
|