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:
+58
-5
@@ -3,6 +3,7 @@
|
||||
#include <QDateTime>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QMessageAuthenticationCode>
|
||||
@@ -16,11 +17,57 @@ static QByteArray ticketMac(const QJsonObject& payload, const QString& secret)
|
||||
QJsonDocument(payload).toJson(QJsonDocument::Compact), secret.toUtf8(), QCryptographicHash::Sha256).toHex();
|
||||
}
|
||||
|
||||
static bool constantTimeEqual(const QByteArray& left, const QByteArray& right)
|
||||
{
|
||||
if (left.size() != right.size()) return false;
|
||||
unsigned char difference = 0;
|
||||
for (qsizetype index = 0; index < left.size(); ++index)
|
||||
difference |= static_cast<unsigned char>(left.at(index) ^ right.at(index));
|
||||
return difference == 0;
|
||||
}
|
||||
|
||||
static bool claimTicketNonce(const QString& appId, const QString& nonce, QString* errorMessage)
|
||||
{
|
||||
const QString claimsRoot = QDir(QStandardPaths::writableLocation(
|
||||
QStandardPaths::AppLocalDataLocation)).filePath("launcher-ticket-claims");
|
||||
if (!QDir().mkpath(claimsRoot)) {
|
||||
if (errorMessage) *errorMessage = "cannot create ticket replay state";
|
||||
return false;
|
||||
}
|
||||
|
||||
QDir claimsDirectory(claimsRoot);
|
||||
const QDateTime staleBefore = QDateTime::currentDateTimeUtc().addSecs(-600);
|
||||
const QFileInfoList oldClaims = claimsDirectory.entryInfoList(
|
||||
QStringList{"*.claimed"}, QDir::Files);
|
||||
for (const QFileInfo& claim : oldClaims)
|
||||
if (claim.lastModified().toUTC() < staleBefore) QFile::remove(claim.absoluteFilePath());
|
||||
|
||||
const QByteArray claimIdentity = appId.toUtf8() + '\0' + nonce.toUtf8();
|
||||
const QString claimName = QString::fromLatin1(
|
||||
QCryptographicHash::hash(claimIdentity, QCryptographicHash::Sha256).toHex()) + ".claimed";
|
||||
QFile claimFile(claimsDirectory.filePath(claimName));
|
||||
if (!claimFile.open(QIODevice::WriteOnly | QIODevice::NewOnly)) {
|
||||
if (errorMessage) *errorMessage = "ticket nonce was already consumed";
|
||||
return false;
|
||||
}
|
||||
const QByteArray marker = QDateTime::currentDateTimeUtc().toString(Qt::ISODate).toUtf8();
|
||||
if (claimFile.write(marker) != marker.size()) {
|
||||
const QString claimPath = claimFile.fileName();
|
||||
claimFile.close();
|
||||
QFile::remove(claimPath);
|
||||
if (errorMessage) *errorMessage = "cannot persist ticket replay state";
|
||||
return false;
|
||||
}
|
||||
claimFile.close();
|
||||
QFile::setPermissions(claimFile.fileName(), QFileDevice::ReadOwner | QFileDevice::WriteOwner);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TicketHelper::createTicket(const QString& appId, const QString& deviceId,
|
||||
const QString& version, const QString& secret,
|
||||
QString* ticketPath, QString* errorMessage)
|
||||
{
|
||||
if (appId.isEmpty() || version.isEmpty() || secret.isEmpty()) {
|
||||
if (appId.isEmpty() || deviceId.isEmpty() || version.isEmpty() || secret.isEmpty()) {
|
||||
if (errorMessage) *errorMessage = "ticket identity or secret is empty";
|
||||
return false;
|
||||
}
|
||||
@@ -51,6 +98,11 @@ bool TicketHelper::consumeAndVerify(const QString& ticketPath, const QString& ex
|
||||
const QString& expectedDeviceId, const QString& expectedVersion,
|
||||
const QString& secret, QString* errorMessage)
|
||||
{
|
||||
if (expectedAppId.isEmpty() || expectedDeviceId.isEmpty()
|
||||
|| expectedVersion.isEmpty() || secret.isEmpty()) {
|
||||
if (errorMessage) *errorMessage = "expected ticket identity or secret is incomplete";
|
||||
return false;
|
||||
}
|
||||
const QString consumingPath = ticketPath + ".consuming."
|
||||
+ QString::number(QCoreApplication::applicationPid());
|
||||
if (!QFile::rename(ticketPath, consumingPath)) {
|
||||
@@ -64,7 +116,7 @@ bool TicketHelper::consumeAndVerify(const QString& ticketPath, const QString& ex
|
||||
return false;
|
||||
}
|
||||
const QByteArray raw = file.readAll(); file.close();
|
||||
QFile::remove(consumingPath); // 一次性消费;无论成功失败都不能重放。
|
||||
QFile::remove(consumingPath); // Consume once so neither successful nor failed claims can be replayed.
|
||||
QJsonParseError parseError;
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(raw, &parseError);
|
||||
if (parseError.error != QJsonParseError::NoError || !doc.isObject()) {
|
||||
@@ -82,10 +134,11 @@ bool TicketHelper::consumeAndVerify(const QString& ticketPath, const QString& ex
|
||||
&& payload.value("version").toString() == expectedVersion;
|
||||
const bool timeOk = issued.isValid() && expires.isValid() && issued <= now.addSecs(5)
|
||||
&& expires >= now && issued.secsTo(expires) <= 65;
|
||||
if (actual.isEmpty() || actual != expected || !identityOk || !timeOk
|
||||
|| payload.value("nonce").toString().isEmpty()) {
|
||||
const QString nonce = payload.value("nonce").toString();
|
||||
if (actual.isEmpty() || !constantTimeEqual(actual, expected) || !identityOk || !timeOk
|
||||
|| nonce.isEmpty()) {
|
||||
if (errorMessage) *errorMessage = "ticket signature, identity, time or nonce invalid";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
return claimTicketNonce(expectedAppId, nonce, errorMessage);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user