feat(hardening): 内嵌产品配置 + 系统级状态 + 全量 i18n + 依赖治理 + 资料清理
update-client-hardening 四工作流合入(构建/静态检查已过): - 配置:不可变产品策略经 UpdateClientResources.cmake 归一化校验后 内嵌 qrc(URL/相对路径/主程序必须落 install_root/版本/UUID 格式, CMake override 写入前复验);可变机器状态迁系统级原生存储; 运行目录不再落可编辑 app_config.json。 - i18n:生产源全英文(no-Han 检查 41/41),中文进 translations/ zh_CN 目录;翻译加载收敛 Common/TranslationHelper 唯一入口; Bootstrap 补资源文件。 - 依赖:Qt/OpenSSL/架构/Perl 机器路径改 imported targets + UpdateClientDependencies.cmake,3rdparty 由父工程契约供给。 - 清理:旧 README/SDK README/3 个重复 PowerShell 打包脚本/PDF 提取 文本删除,canonical README + 5 份结构化英文文档替代;Launcher requireAdministrator manifest;统一 MSVC /utf-8 删运行时编码设置。
This commit is contained in:
+341
-24
@@ -1,10 +1,13 @@
|
||||
#include "DeviceIdentityHelper.h"
|
||||
|
||||
#include "ConfigHelper.h"
|
||||
|
||||
#include <QCryptographicHash>
|
||||
#include <QDateTime>
|
||||
#include <QDir>
|
||||
#include <QEventLoop>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QNetworkAccessManager>
|
||||
@@ -12,38 +15,352 @@
|
||||
#include <QNetworkRequest>
|
||||
#include <QSaveFile>
|
||||
#include <QSysInfo>
|
||||
#include <QUuid>
|
||||
#include <QTimer>
|
||||
#include <QUuid>
|
||||
|
||||
#ifdef HAVE_OPENSSL
|
||||
#include <openssl/evp.h>
|
||||
#include <openssl/pem.h>
|
||||
#endif
|
||||
DeviceIdentityHelper::DeviceIdentityHelper(const QString& dir):m_installDir(dir){}
|
||||
QString DeviceIdentityHelper::deviceId() const{return m_deviceId;}
|
||||
QString DeviceIdentityHelper::errorString() const{return m_error;}
|
||||
bool DeviceIdentityHelper::verifySignature(const QByteArray& payload,const QString& sig64){
|
||||
|
||||
namespace
|
||||
{
|
||||
QString machineHash(const QString& installationId)
|
||||
{
|
||||
const QByteArray identity =
|
||||
QSysInfo::machineUniqueId() + installationId.toUtf8();
|
||||
return QString::fromLatin1(
|
||||
QCryptographicHash::hash(identity, QCryptographicHash::Sha256).toHex());
|
||||
}
|
||||
}
|
||||
|
||||
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(sig64);m_error="OpenSSL unavailable";return false;
|
||||
Q_UNUSED(payload);
|
||||
Q_UNUSED(signatureBase64);
|
||||
m_error = "OpenSSL unavailable";
|
||||
return false;
|
||||
#else
|
||||
QFile f(QDir(m_installDir).filePath("config/manifest_public_key.pem")); if(!f.open(QIODevice::ReadOnly)){m_error="device public key missing";return false;}
|
||||
QByteArray kd=f.readAll();BIO* b=BIO_new_mem_buf(kd.constData(),kd.size());EVP_PKEY* k=b?PEM_read_bio_PUBKEY(b,nullptr,nullptr,nullptr):nullptr;if(b)BIO_free(b);if(!k){m_error="device public key invalid";return false;}
|
||||
EVP_MD_CTX* c=EVP_MD_CTX_new();QByteArray sig=QByteArray::fromBase64(sig64.toUtf8());bool ok=c&&EVP_DigestVerifyInit(c,nullptr,EVP_sha256(),nullptr,k)==1&&EVP_DigestVerifyUpdate(c,payload.constData(),payload.size())==1&&EVP_DigestVerifyFinal(c,reinterpret_cast<const unsigned char*>(sig.constData()),sig.size())==1;if(c)EVP_MD_CTX_free(c);EVP_PKEY_free(k);if(!ok)m_error="device credential RSA signature invalid";return ok;
|
||||
QFile keyFile(
|
||||
QStringLiteral(":/simcae/update-client/manifest-public-key.pem"));
|
||||
if (!keyFile.open(QIODevice::ReadOnly))
|
||||
{
|
||||
m_error = "embedded device public key missing";
|
||||
return false;
|
||||
}
|
||||
|
||||
const QByteArray keyData = keyFile.readAll();
|
||||
BIO* bio = BIO_new_mem_buf(keyData.constData(), keyData.size());
|
||||
EVP_PKEY* key = bio
|
||||
? PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr)
|
||||
: nullptr;
|
||||
if (bio)
|
||||
BIO_free(bio);
|
||||
if (!key)
|
||||
{
|
||||
m_error = "device public key invalid";
|
||||
return false;
|
||||
}
|
||||
|
||||
EVP_MD_CTX* context = EVP_MD_CTX_new();
|
||||
const QByteArray signature =
|
||||
QByteArray::fromBase64(signatureBase64.toUtf8());
|
||||
const bool valid =
|
||||
context
|
||||
&& EVP_DigestVerifyInit(
|
||||
context, nullptr, EVP_sha256(), nullptr, key)
|
||||
== 1
|
||||
&& EVP_DigestVerifyUpdate(
|
||||
context, payload.constData(), payload.size())
|
||||
== 1
|
||||
&& EVP_DigestVerifyFinal(
|
||||
context,
|
||||
reinterpret_cast<const unsigned char*>(signature.constData()),
|
||||
signature.size())
|
||||
== 1;
|
||||
|
||||
if (context)
|
||||
EVP_MD_CTX_free(context);
|
||||
EVP_PKEY_free(key);
|
||||
if (!valid)
|
||||
m_error = "device credential RSA signature invalid";
|
||||
return valid;
|
||||
#endif
|
||||
}
|
||||
bool DeviceIdentityHelper::loadAndVerify(const QString& appId,const QString& channel){
|
||||
QFile f(QDir(m_installDir).filePath("config/client_identity.dat"));if(!f.open(QIODevice::ReadOnly))return false;QJsonParseError e;auto d=QJsonDocument::fromJson(f.readAll(),&e);if(e.error!=QJsonParseError::NoError||!d.isObject()){m_error="device credential JSON invalid";return false;}auto w=d.object();QByteArray text=w.value("identity_text").toString().toUtf8();if(text.isEmpty()||!verifySignature(text,w.value("signature").toString()))return false;auto identity=QJsonDocument::fromJson(text).object();QDateTime expiry=QDateTime::fromString(identity.value("valid_until").toString(),Qt::ISODate);if(identity.value("app_id").toString()!=appId||identity.value("channel").toString()!=channel||identity.value("license_id").toString().isEmpty()||identity.value("installation_id").toString().isEmpty()||identity.value("device_id").toString().isEmpty()){m_error="device/license credential identity mismatch";return false;}if(!expiry.isValid()||expiry<=QDateTime::currentDateTimeUtc()){m_error="license expired";return false;}m_deviceId=identity.value("device_id").toString();return true;
|
||||
|
||||
bool DeviceIdentityHelper::loadAndVerify(const QString& expectedAppId,
|
||||
const QString& expectedChannel)
|
||||
{
|
||||
QFile credential(
|
||||
QDir(m_installDir).filePath("config/client_identity.dat"));
|
||||
if (!credential.open(QIODevice::ReadOnly))
|
||||
{
|
||||
m_error = "device credential is missing or unreadable";
|
||||
return false;
|
||||
}
|
||||
|
||||
QJsonParseError wrapperError;
|
||||
const QJsonDocument wrapperDocument =
|
||||
QJsonDocument::fromJson(credential.readAll(), &wrapperError);
|
||||
if (wrapperError.error != QJsonParseError::NoError
|
||||
|| !wrapperDocument.isObject())
|
||||
{
|
||||
m_error = "device credential JSON invalid";
|
||||
return false;
|
||||
}
|
||||
|
||||
const QJsonObject wrapper = wrapperDocument.object();
|
||||
const QByteArray identityText =
|
||||
wrapper.value("identity_text").toString().toUtf8();
|
||||
if (identityText.isEmpty()
|
||||
|| !verifySignature(
|
||||
identityText, wrapper.value("signature").toString()))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
QJsonParseError identityError;
|
||||
const QJsonDocument identityDocument =
|
||||
QJsonDocument::fromJson(identityText, &identityError);
|
||||
if (identityError.error != QJsonParseError::NoError
|
||||
|| !identityDocument.isObject())
|
||||
{
|
||||
m_error = "signed device identity JSON invalid";
|
||||
return false;
|
||||
}
|
||||
|
||||
const QJsonObject identity = identityDocument.object();
|
||||
const QString signedInstallationId =
|
||||
identity.value("installation_id").toString();
|
||||
const QString signedDeviceId = identity.value("device_id").toString();
|
||||
ConfigHelper& config = ConfigHelper::instance();
|
||||
const QString localInstallationId =
|
||||
config.getValue("Device", "installation_id").trimmed();
|
||||
const QString localDeviceId =
|
||||
config.getValue("Update", "device_id").trimmed();
|
||||
|
||||
if (identity.value("app_id").toString() != expectedAppId
|
||||
|| identity.value("channel").toString() != expectedChannel
|
||||
|| identity.value("license_id").toString().isEmpty()
|
||||
|| signedInstallationId.isEmpty() || signedDeviceId.isEmpty())
|
||||
{
|
||||
m_error = "device/license credential identity mismatch";
|
||||
return false;
|
||||
}
|
||||
if (localInstallationId.isEmpty()
|
||||
|| signedInstallationId != localInstallationId)
|
||||
{
|
||||
m_error = "device credential belongs to another installation";
|
||||
return false;
|
||||
}
|
||||
if (!localDeviceId.isEmpty() && signedDeviceId != localDeviceId)
|
||||
{
|
||||
m_error = "device credential does not match the registered device";
|
||||
return false;
|
||||
}
|
||||
|
||||
const QString signedMachineHash =
|
||||
identity.value("machine_hash").toString();
|
||||
if (!signedMachineHash.isEmpty()
|
||||
&& signedMachineHash.compare(
|
||||
machineHash(localInstallationId), Qt::CaseInsensitive)
|
||||
!= 0)
|
||||
{
|
||||
m_error = "device credential belongs to another machine";
|
||||
return false;
|
||||
}
|
||||
|
||||
const QDateTime expiry = QDateTime::fromString(
|
||||
identity.value("valid_until").toString(), Qt::ISODate);
|
||||
if (!expiry.isValid() || expiry <= QDateTime::currentDateTimeUtc())
|
||||
{
|
||||
m_error = "license expired";
|
||||
return false;
|
||||
}
|
||||
|
||||
m_deviceId = signedDeviceId;
|
||||
return true;
|
||||
}
|
||||
bool DeviceIdentityHelper::verifyLocal(const QString& appId,const QString& channel){m_error.clear();return loadAndVerify(appId,channel);}
|
||||
bool DeviceIdentityHelper::ensureIssued(const QString& base,const QString& token,const QString& appId,const QString& channel,const QString& licenseKey){
|
||||
m_error.clear();if(loadAndVerify(appId,channel)){ConfigHelper::instance().setValue("Update","device_id",m_deviceId);return true;}
|
||||
const QString trimmedBase=base.trimmed();
|
||||
if(appId.trimmed().isEmpty()){m_error="app_id is empty in config/app_config.json";return false;}
|
||||
if(channel.trimmed().isEmpty()){m_error="channel is empty in config/app_config.json";return false;}
|
||||
if(trimmedBase.isEmpty()||trimmedBase.contains("YOUR_SERVER_IP",Qt::CaseInsensitive)){m_error="api_base_url is not configured. Set it to the update server address, for example http://192.168.229.128:8000";return false;}
|
||||
if(token.trimmed().isEmpty()){m_error="client_token is empty in config/app_config.json";return false;}
|
||||
if(licenseKey.trimmed().isEmpty()){m_error="license_key is empty. Create a License in the admin page and paste the generated key into config/app_config.json";return false;}
|
||||
ConfigHelper& config=ConfigHelper::instance();
|
||||
QString installation=config.getValue("Device","installation_id");if(installation.isEmpty()){installation=QUuid::createUuid().toString(QUuid::WithoutBraces);if(!config.setValue("Device","installation_id",installation)){m_error=QString("cannot save installation id to %1: %2").arg(config.configPath(),config.lastError());return false;}}
|
||||
QByteArray machine=QSysInfo::machineUniqueId()+installation.toUtf8();QString mh=QString::fromLatin1(QCryptographicHash::hash(machine,QCryptographicHash::Sha256).toHex());QJsonObject body{{"app_id",appId},{"channel",channel},{"license_key",licenseKey},{"installation_id",installation},{"machine_hash",mh}};
|
||||
QNetworkAccessManager manager;QNetworkRequest req{QUrl(trimmedBase+"/api/v1/device/issue")};req.setHeader(QNetworkRequest::ContentTypeHeader,"application/json");req.setRawHeader("X-Client-Token",token.toUtf8());QNetworkReply* reply=manager.post(req,QJsonDocument(body).toJson(QJsonDocument::Compact));QEventLoop loop;bool timeoutOk=false;int timeoutMs=ConfigHelper::instance().getValue("Update","request_timeout_ms").toInt(&timeoutOk);if(!timeoutOk||timeoutMs<1000)timeoutMs=5000;QTimer timer;timer.setSingleShot(true);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();int status=reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();QByteArray raw=reply->readAll();reply->deleteLater();if(status!=200){m_error=QString("device issue failed (HTTP %1): %2").arg(status).arg(QString::fromUtf8(raw));return false;}auto response=QJsonDocument::fromJson(raw).object();QJsonObject wrapper{{"identity_text",response.value("identity_text")},{"signature",response.value("signature")}};QString path=QDir(m_installDir).filePath("config/client_identity.dat");QSaveFile out(path);QByteArray bytes=QJsonDocument(wrapper).toJson(QJsonDocument::Compact);if(!out.open(QIODevice::WriteOnly)||out.write(bytes)!=bytes.size()||!out.commit()){m_error=QString("cannot save device credential to %1: %2").arg(path,out.errorString());return false;}if(!loadAndVerify(appId,channel))return false;if(!config.setValue("Update","device_id",m_deviceId)){m_error=QString("cannot save server device id to %1: %2").arg(config.configPath(),config.lastError());return false;}return true;
|
||||
|
||||
bool DeviceIdentityHelper::verifyLocal(const QString& appId,
|
||||
const QString& channel)
|
||||
{
|
||||
m_error.clear();
|
||||
m_deviceId.clear();
|
||||
return loadAndVerify(appId, channel);
|
||||
}
|
||||
|
||||
bool DeviceIdentityHelper::ensureIssued(const QString& apiBaseUrl,
|
||||
const QString& clientToken,
|
||||
const QString& appId,
|
||||
const QString& channel,
|
||||
const QString& licenseKey)
|
||||
{
|
||||
m_error.clear();
|
||||
m_deviceId.clear();
|
||||
ConfigHelper& config = ConfigHelper::instance();
|
||||
if (loadAndVerify(appId, channel))
|
||||
{
|
||||
if (!config.setValue("Update", "device_id", m_deviceId))
|
||||
{
|
||||
m_error = QString("cannot save server device id to %1: %2")
|
||||
.arg(config.configPath(), config.lastError());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const QString server = apiBaseUrl.trimmed();
|
||||
if (appId.trimmed().isEmpty())
|
||||
{
|
||||
m_error = "app_id is empty in the embedded product configuration";
|
||||
return false;
|
||||
}
|
||||
if (channel.trimmed().isEmpty())
|
||||
{
|
||||
m_error = "channel is empty in the embedded product configuration";
|
||||
return false;
|
||||
}
|
||||
if (server.isEmpty()
|
||||
|| server.contains("YOUR_SERVER_IP", Qt::CaseInsensitive))
|
||||
{
|
||||
m_error = "api_base_url is not configured";
|
||||
return false;
|
||||
}
|
||||
if (clientToken.trimmed().isEmpty())
|
||||
{
|
||||
m_error = "client_token is empty in the embedded product configuration";
|
||||
return false;
|
||||
}
|
||||
if (licenseKey.trimmed().isEmpty())
|
||||
{
|
||||
m_error = "license_key is empty";
|
||||
return false;
|
||||
}
|
||||
|
||||
QString installationId =
|
||||
config.getValue("Device", "installation_id").trimmed();
|
||||
if (installationId.isEmpty())
|
||||
{
|
||||
installationId =
|
||||
QUuid::createUuid().toString(QUuid::WithoutBraces);
|
||||
if (!config.setValue("Device", "installation_id", installationId))
|
||||
{
|
||||
m_error = QString("cannot save installation id to %1: %2")
|
||||
.arg(config.configPath(), config.lastError());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const QJsonObject requestBody{
|
||||
{"app_id", appId},
|
||||
{"channel", channel},
|
||||
{"license_key", licenseKey},
|
||||
{"installation_id", installationId},
|
||||
{"machine_hash", machineHash(installationId)}};
|
||||
QNetworkAccessManager manager;
|
||||
QNetworkRequest request{QUrl(server + "/api/v1/device/issue")};
|
||||
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
|
||||
request.setRawHeader("X-Client-Token", clientToken.toUtf8());
|
||||
QNetworkReply* reply = manager.post(
|
||||
request, QJsonDocument(requestBody).toJson(QJsonDocument::Compact));
|
||||
|
||||
QEventLoop loop;
|
||||
bool timeoutValid = false;
|
||||
int timeoutMs =
|
||||
config.getValue("Update", "request_timeout_ms").toInt(&timeoutValid);
|
||||
if (!timeoutValid || timeoutMs < 1000)
|
||||
timeoutMs = 5000;
|
||||
QTimer timer;
|
||||
timer.setSingleShot(true);
|
||||
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 QByteArray responseBytes = reply->readAll();
|
||||
const QString networkError = reply->errorString();
|
||||
reply->deleteLater();
|
||||
if (status != 200)
|
||||
{
|
||||
m_error = QString("device issue failed (HTTP %1): %2 %3")
|
||||
.arg(status)
|
||||
.arg(networkError, QString::fromUtf8(responseBytes));
|
||||
return false;
|
||||
}
|
||||
|
||||
QJsonParseError responseError;
|
||||
const QJsonDocument responseDocument =
|
||||
QJsonDocument::fromJson(responseBytes, &responseError);
|
||||
if (responseError.error != QJsonParseError::NoError
|
||||
|| !responseDocument.isObject())
|
||||
{
|
||||
m_error = "device issue response is invalid JSON";
|
||||
return false;
|
||||
}
|
||||
const QJsonObject response = responseDocument.object();
|
||||
const QJsonObject wrapper{
|
||||
{"identity_text", response.value("identity_text")},
|
||||
{"signature", response.value("signature")}};
|
||||
if (!wrapper.value("identity_text").isString()
|
||||
|| wrapper.value("identity_text").toString().isEmpty()
|
||||
|| !wrapper.value("signature").isString()
|
||||
|| wrapper.value("signature").toString().isEmpty())
|
||||
{
|
||||
m_error = "device issue response is missing signed identity fields";
|
||||
return false;
|
||||
}
|
||||
|
||||
const QString credentialPath =
|
||||
QDir(m_installDir).filePath("config/client_identity.dat");
|
||||
if (!QDir().mkpath(QFileInfo(credentialPath).path()))
|
||||
{
|
||||
m_error = "cannot create the device credential directory";
|
||||
return false;
|
||||
}
|
||||
QSaveFile output(credentialPath);
|
||||
const QByteArray credentialBytes =
|
||||
QJsonDocument(wrapper).toJson(QJsonDocument::Compact);
|
||||
if (!output.open(QIODevice::WriteOnly)
|
||||
|| output.write(credentialBytes) != credentialBytes.size()
|
||||
|| !output.commit())
|
||||
{
|
||||
m_error = QString("cannot save device credential to %1: %2")
|
||||
.arg(credentialPath, output.errorString());
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!loadAndVerify(appId, channel))
|
||||
return false;
|
||||
if (!config.setValue("Update", "device_id", m_deviceId))
|
||||
{
|
||||
m_error = QString("cannot save server device id to %1: %2")
|
||||
.arg(config.configPath(), config.lastError());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user