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:
Comely
2026-07-10 00:38:23 -07:00
parent b06e003502
commit fe5aa5bbf0
45 changed files with 5033 additions and 4083 deletions
+14 -19
View File
@@ -1,7 +1,4 @@
project(Common LANGUAGES C CXX)
find_package(Qt5 REQUIRED COMPONENTS Core Network Widgets)
set(SRC
set(_common_sources
HttpHelper.h
HttpHelper.cpp
FileHelper.h
@@ -18,21 +15,19 @@ set(SRC
IntegrityHelper.cpp
DeviceIdentityHelper.h
DeviceIdentityHelper.cpp
)
add_library(Common STATIC ${SRC})
# Common编译自身需要OpenSSL头文件
target_include_directories(Common
PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}
PRIVATE ${OPENSSL_INC}
TranslationHelper.h
TranslationHelper.cpp
)
# 链接库、库目录通过INTERFACE传递给所有依赖Common的exe
target_link_directories(Common INTERFACE
$<$<CONFIG:Debug>:${OPENSSL_LIB_DEBUG}>
$<$<CONFIG:Release>:${OPENSSL_LIB_RELEASE}>
)
add_library(Common STATIC ${_common_sources})
target_compile_features(Common PUBLIC cxx_std_17)
target_compile_definitions(Common PUBLIC HAVE_OPENSSL=1)
target_include_directories(Common PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}")
target_link_libraries(Common
PRIVATE Qt5::Core Qt5::Network Qt5::Widgets
INTERFACE libssl.lib libcrypto.lib
)
PUBLIC
Qt5::Core
Qt5::Network
Qt5::Widgets
OpenSSL::SSL
OpenSSL::Crypto
)
+565 -105
View File
@@ -1,13 +1,162 @@
#include "ConfigHelper.h"
#include <QApplication>
#include <QCoreApplication>
#include <QDebug>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonDocument>
#include <QJsonObject>
#include <QSaveFile>
#include <QJsonParseError>
#include <QRegularExpression>
#include <QSettings>
#include <QDebug>
#include <QUrl>
#include <QUuid>
namespace
{
constexpr auto kProductConfigResource = ":/simcae/update-client/product-config.json";
constexpr auto kSettingsOrganization = "SimCAE";
constexpr auto kSettingsApplication = "UpdateClient";
constexpr auto kMigrationMarker = "meta/legacy_state_migration_version";
constexpr int kMigrationVersion = 1;
const QStringList& mutableKeys()
{
static const QStringList keys{
QStringLiteral("current_version"),
QStringLiteral("license_key"),
QStringLiteral("device_id"),
QStringLiteral("installation_id")
};
return keys;
}
const QStringList& legacyMigratableKeys()
{
static const QStringList keys{
QStringLiteral("license_key"),
QStringLiteral("device_id"),
QStringLiteral("installation_id")
};
return keys;
}
QString settingsStatusText(QSettings::Status status)
{
switch (status)
{
case QSettings::NoError:
return QStringLiteral("no error");
case QSettings::AccessError:
return QStringLiteral("access denied");
case QSettings::FormatError:
return QStringLiteral("invalid settings format");
}
return QStringLiteral("unknown settings error");
}
bool isSafeIdentifier(const QString& value)
{
static const QRegularExpression pattern(QStringLiteral("^[A-Za-z0-9._-]+$"));
return pattern.match(value).hasMatch();
}
bool isStrictRelativePath(const QString& value)
{
const QString relative = QDir::fromNativeSeparators(value.trimmed());
if (relative.isEmpty()
|| QDir::isAbsolutePath(relative)
|| relative.contains(QLatin1Char(':'))
|| QDir::cleanPath(relative) != relative)
{
return false;
}
const QStringList segments = relative.split(QLatin1Char('/'));
for (const QString& segment : segments)
{
if (segment.isEmpty() || segment == QStringLiteral(".") || segment == QStringLiteral(".."))
return false;
}
return true;
}
bool isPathWithinRoot(const QString& rootPath, const QString& candidatePath)
{
const QString relative = QDir::fromNativeSeparators(
QDir(rootPath).relativeFilePath(candidatePath));
return !QDir::isAbsolutePath(relative)
&& relative != QStringLiteral("..")
&& !relative.startsWith(QStringLiteral("../"));
}
bool isControlledMainExecutablePath(const QString& value, const QString& installRoot)
{
const QString relative = QDir::fromNativeSeparators(value.trimmed());
if (relative.isEmpty()
|| QDir::isAbsolutePath(relative)
|| relative.contains(QLatin1Char(':'))
|| QDir::cleanPath(relative) != relative
|| relative == QStringLiteral(".")
|| relative == QStringLiteral(".."))
{
return false;
}
const QString runtimeRoot = QDir::cleanPath(QCoreApplication::applicationDirPath());
const QString approvedRoot = QDir::cleanPath(QDir(runtimeRoot).filePath(installRoot));
const QString executablePath = QDir::cleanPath(QDir(runtimeRoot).filePath(relative));
return isPathWithinRoot(approvedRoot, executablePath);
}
bool isPositiveInteger(const QJsonValue& value)
{
if (!value.isString() && !value.isDouble())
return false;
bool ok = false;
const int number = value.toVariant().toString().toInt(&ok);
return ok && number > 0;
}
bool hasControlCharacter(const QString& value)
{
for (const QChar character : value)
{
if (character.category() == QChar::Other_Control)
return true;
}
return false;
}
bool normalizeMutableStateValue(const QString& key, const QString& rawValue, QString* normalizedValue)
{
const QString value = rawValue.trimmed();
bool valid = false;
if (key == QStringLiteral("current_version"))
{
static const QRegularExpression versionPattern(
QStringLiteral("^[0-9A-Za-z][0-9A-Za-z._+-]{0,127}$"));
valid = versionPattern.match(value).hasMatch();
}
else if (key == QStringLiteral("installation_id"))
{
valid = value.isEmpty() || !QUuid(value).isNull();
}
else if (key == QStringLiteral("device_id"))
{
valid = value.size() <= 256 && !hasControlCharacter(value);
}
else if (key == QStringLiteral("license_key"))
{
valid = value.size() <= 4096 && !hasControlCharacter(value);
}
if (valid && normalizedValue)
*normalizedValue = value;
return valid;
}
}
ConfigHelper& ConfigHelper::instance()
{
@@ -16,43 +165,73 @@ ConfigHelper& ConfigHelper::instance()
}
ConfigHelper::ConfigHelper()
: m_productConfigPath(QString::fromLatin1(kProductConfigResource))
{
m_configPath = QApplication::applicationDirPath() + "/config/app_config.json";
migrateLegacyIniIfNeeded();
qDebug() << "Loading app config path:" << m_configPath;
qDebug() << "File exists?" << QFile::exists(m_configPath);
if (!loadProductConfig())
{
qCritical().noquote() << m_error;
return;
}
m_statePrefix = QStringLiteral("products/%1/%2/")
.arg(m_productConfig.value(QStringLiteral("app_id")).toString(),
m_productConfig.value(QStringLiteral("channel")).toString());
if (!migrateLegacyStateIfNeeded())
qWarning().noquote() << m_error;
if (!initializeStateDefaults())
qWarning().noquote() << m_error;
}
QString ConfigHelper::configPath() const
{
return m_configPath;
return stateLocation();
}
QString ConfigHelper::productConfigPath() const
{
return m_productConfigPath;
}
QString ConfigHelper::stateLocation() const
{
QSettings settings(QSettings::NativeFormat, QSettings::SystemScope,
QString::fromLatin1(kSettingsOrganization),
QString::fromLatin1(kSettingsApplication));
settings.setFallbacksEnabled(false);
const QString nativeLocation = settings.fileName();
if (!nativeLocation.isEmpty())
return nativeLocation;
return QStringLiteral("Native system settings: %1/%2")
.arg(QString::fromLatin1(kSettingsOrganization),
QString::fromLatin1(kSettingsApplication));
}
QString ConfigHelper::installRoot() const
{
QString relativeRoot = getValue("Runtime", "install_root").trimmed();
QString relativeRoot = getValue(QStringLiteral("Runtime"), QStringLiteral("install_root")).trimmed();
if (relativeRoot.isEmpty())
relativeRoot = ".";
relativeRoot = QStringLiteral(".");
return QDir::cleanPath(QDir(runtimeRoot()).filePath(relativeRoot));
}
QString ConfigHelper::runtimeRoot() const
{
return QDir::cleanPath(QApplication::applicationDirPath());
return QDir::cleanPath(QCoreApplication::applicationDirPath());
}
QString ConfigHelper::updateRoot() const
{
return QDir(runtimeRoot()).filePath("update");
return QDir(runtimeRoot()).filePath(QStringLiteral("update"));
}
QString ConfigHelper::runtimeRelativePath() const
{
QString relative = QDir::fromNativeSeparators(QDir(installRoot()).relativeFilePath(runtimeRoot()));
relative = QDir::cleanPath(relative);
if (relative == ".")
if (relative == QStringLiteral("."))
return QString();
while (relative.startsWith("./"))
while (relative.startsWith(QStringLiteral("./")))
relative = relative.mid(2);
return relative.trimmed();
}
@@ -65,16 +244,9 @@ QString ConfigHelper::lastError() const
QString ConfigHelper::getValue(const QString& section, const QString& key) const
{
Q_UNUSED(section);
QFile file(m_configPath);
if (!file.open(QIODevice::ReadOnly))
return QString();
QJsonParseError error;
const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &error);
if (error.error != QJsonParseError::NoError || !document.isObject())
return QString();
return document.object().value(key).toVariant().toString();
if (isMutableKey(key))
return stateValue(key);
return m_productConfig.value(key).toVariant().toString();
}
bool ConfigHelper::setValue(const QString& section, const QString& key, const QString& value)
@@ -82,100 +254,388 @@ bool ConfigHelper::setValue(const QString& section, const QString& key, const QS
Q_UNUSED(section);
m_error.clear();
QFile input(m_configPath);
QJsonObject config;
if (input.exists())
if (!isMutableKey(key))
{
if (!input.open(QIODevice::ReadOnly))
{
m_error = QString("Cannot open config for reading: %1").arg(input.errorString());
return false;
}
QJsonParseError parseError;
const QByteArray raw = input.readAll();
input.close();
const QJsonDocument document = QJsonDocument::fromJson(raw, &parseError);
if (parseError.error != QJsonParseError::NoError || !document.isObject())
{
m_error = QString("Invalid config JSON: %1").arg(parseError.errorString());
return false;
}
config = document.object();
}
config.insert(key, value);
QDir dir(QFileInfo(m_configPath).path());
if (!dir.exists() && !dir.mkpath("."))
{
m_error = QString("Cannot create config directory: %1").arg(dir.path());
m_error = QStringLiteral("Configuration key '%1' is immutable and cannot be changed at runtime.")
.arg(key);
return false;
}
QSaveFile output(m_configPath);
if (!output.open(QIODevice::WriteOnly))
if (m_statePrefix.isEmpty())
{
m_error = QString("Cannot open config for writing: %1").arg(output.errorString());
m_error = QStringLiteral("Runtime state is unavailable because product configuration was not loaded.");
return false;
}
const QByteArray payload = QJsonDocument(config).toJson(QJsonDocument::Indented);
if (output.write(payload) != payload.size())
QString normalizedValue;
if (!normalizeMutableStateValue(key, value, &normalizedValue))
{
m_error = QString("Cannot write full config file: %1").arg(output.errorString());
output.cancelWriting();
m_error = QStringLiteral("Runtime state key '%1' has an invalid value.").arg(key);
return false;
}
if (!output.commit())
return writeStateValue(key, normalizedValue);
}
bool ConfigHelper::loadProductConfig()
{
QFile file(m_productConfigPath);
if (!file.open(QIODevice::ReadOnly))
{
m_error = QString("Cannot commit config file: %1").arg(output.errorString());
m_error = QStringLiteral("Cannot open embedded product configuration '%1': %2")
.arg(m_productConfigPath, file.errorString());
return false;
}
QJsonParseError parseError;
const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &parseError);
if (parseError.error != QJsonParseError::NoError || !document.isObject())
{
m_error = QStringLiteral("Embedded product configuration is invalid JSON at offset %1: %2")
.arg(parseError.offset)
.arg(parseError.errorString());
return false;
}
const QJsonObject config = document.object();
const QJsonValue schemaVersion = config.value(QStringLiteral("schema_version"));
if (!schemaVersion.isDouble() || schemaVersion.toDouble(-1.0) != 1.0)
{
m_error = QStringLiteral("Embedded product configuration schema_version must be 1.");
return false;
}
const QStringList requiredTextKeys{
QStringLiteral("app_id"),
QStringLiteral("app_name"),
QStringLiteral("channel"),
QStringLiteral("launch_token"),
QStringLiteral("api_base_url"),
QStringLiteral("client_token"),
QStringLiteral("temp_folder"),
QStringLiteral("install_root"),
QStringLiteral("main_executable"),
QStringLiteral("launcher_executable"),
QStringLiteral("updater_executable"),
QStringLiteral("bootstrap_executable"),
QStringLiteral("platform"),
QStringLiteral("arch")
};
for (const QString& key : requiredTextKeys)
{
const QJsonValue value = config.value(key);
if (!value.isString() || value.toString().trimmed().isEmpty())
{
m_error = QStringLiteral("Embedded product configuration key '%1' must be a non-empty string.")
.arg(key);
return false;
}
}
for (const QString& key : mutableKeys())
{
if (config.contains(key))
{
m_error = QStringLiteral("Mutable key '%1' must not appear in the product configuration root.")
.arg(key);
return false;
}
}
if (!isSafeIdentifier(config.value(QStringLiteral("app_id")).toString())
|| !isSafeIdentifier(config.value(QStringLiteral("channel")).toString()))
{
m_error = QStringLiteral("Product app_id and channel may contain only letters, digits, '.', '_' and '-'.");
return false;
}
const QUrl apiUrl(config.value(QStringLiteral("api_base_url")).toString());
if (!apiUrl.isValid()
|| apiUrl.host().isEmpty()
|| (apiUrl.scheme().compare(QStringLiteral("http"), Qt::CaseInsensitive) != 0
&& apiUrl.scheme().compare(QStringLiteral("https"), Qt::CaseInsensitive) != 0))
{
m_error = QStringLiteral("Product api_base_url must be a valid HTTP or HTTPS URL.");
return false;
}
const QStringList positiveIntegerKeys{
QStringLiteral("client_protocol"),
QStringLiteral("request_timeout_ms"),
QStringLiteral("health_check_timeout_ms")
};
for (const QString& key : positiveIntegerKeys)
{
if (!isPositiveInteger(config.value(key)))
{
m_error = QStringLiteral("Embedded product configuration key '%1' must be a positive integer.")
.arg(key);
return false;
}
}
const QString installRoot = QDir::fromNativeSeparators(
config.value(QStringLiteral("install_root")).toString().trimmed());
if (QDir::isAbsolutePath(installRoot)
|| installRoot.contains(QLatin1Char(':'))
|| QDir::cleanPath(installRoot) != installRoot)
{
m_error = QStringLiteral("Product install_root must be a normalized relative path.");
return false;
}
if (!isSafeIdentifier(config.value(QStringLiteral("platform")).toString())
|| !isSafeIdentifier(config.value(QStringLiteral("arch")).toString()))
{
m_error = QStringLiteral("Product platform and arch may contain only letters, digits, '.', '_' and '-'.");
return false;
}
if (!isControlledMainExecutablePath(
config.value(QStringLiteral("main_executable")).toString(), installRoot))
{
m_error = QStringLiteral(
"Embedded product configuration key 'main_executable' must stay within install_root.");
return false;
}
const QStringList restrictedRelativePaths{
QStringLiteral("temp_folder"),
QStringLiteral("launcher_executable"),
QStringLiteral("updater_executable"),
QStringLiteral("bootstrap_executable")
};
for (const QString& key : restrictedRelativePaths)
{
if (!isStrictRelativePath(config.value(key).toString()))
{
m_error = QStringLiteral("Embedded product configuration key '%1' must be a safe relative path.")
.arg(key);
return false;
}
}
const QJsonValue initialStateValue = config.value(QStringLiteral("initial_state"));
if (!initialStateValue.isObject())
{
m_error = QStringLiteral("Embedded product configuration must contain an initial_state object.");
return false;
}
const QJsonObject initialState = initialStateValue.toObject();
QJsonObject normalizedInitialState;
for (auto it = initialState.constBegin(); it != initialState.constEnd(); ++it)
{
if (!isMutableKey(it.key()) || !it.value().isString())
{
m_error = QStringLiteral("Initial state key '%1' is unsupported or is not a string.").arg(it.key());
return false;
}
QString normalizedValue;
if (!normalizeMutableStateValue(it.key(), it.value().toString(), &normalizedValue))
{
m_error = QStringLiteral("Initial state key '%1' has an invalid value.").arg(it.key());
return false;
}
normalizedInitialState.insert(it.key(), normalizedValue);
}
if (!normalizedInitialState.contains(QStringLiteral("current_version")))
{
m_error = QStringLiteral("Initial state current_version must be a non-empty string.");
return false;
}
m_productConfig = config;
m_productConfig.remove(QStringLiteral("initial_state"));
m_initialState = normalizedInitialState;
return true;
}
bool ConfigHelper::migrateLegacyStateIfNeeded()
{
QSettings settings(QSettings::NativeFormat, QSettings::SystemScope,
QString::fromLatin1(kSettingsOrganization),
QString::fromLatin1(kSettingsApplication));
settings.setFallbacksEnabled(false);
const QString markerKey = m_statePrefix + QString::fromLatin1(kMigrationMarker);
if (settings.value(markerKey).toInt() >= kMigrationVersion)
return true;
bool allStateKeysPresent = true;
for (const QString& key : legacyMigratableKeys())
allStateKeysPresent = allStateKeysPresent && settings.contains(stateStorageKey(key));
QJsonObject iniValues;
QJsonObject jsonValues;
const QString legacyIniPath = QDir(QCoreApplication::applicationDirPath())
.filePath(QStringLiteral("client.ini"));
if (!allStateKeysPresent && QFile::exists(legacyIniPath))
{
QSettings ini(legacyIniPath, QSettings::IniFormat);
ini.setFallbacksEnabled(false);
const auto importIniValue = [&](const QString& section, const QString& key) {
const QString legacyKey = section + QLatin1Char('/') + key;
if (ini.contains(legacyKey))
iniValues.insert(key, ini.value(legacyKey).toString());
};
importIniValue(QStringLiteral("License"), QStringLiteral("license_key"));
importIniValue(QStringLiteral("Update"), QStringLiteral("device_id"));
importIniValue(QStringLiteral("Device"), QStringLiteral("installation_id"));
if (ini.status() != QSettings::NoError)
qWarning().noquote() << QStringLiteral("Cannot read legacy INI state '%1': %2")
.arg(legacyIniPath, settingsStatusText(ini.status()));
}
const QString legacyJsonPath = QDir(QCoreApplication::applicationDirPath())
.filePath(QStringLiteral("config/app_config.json"));
QFile legacyJson(legacyJsonPath);
if (!allStateKeysPresent && legacyJson.exists())
{
if (!legacyJson.open(QIODevice::ReadOnly))
{
qWarning().noquote() << QStringLiteral("Cannot read legacy JSON state '%1': %2")
.arg(legacyJsonPath, legacyJson.errorString());
}
else
{
QJsonParseError legacyParseError;
const QJsonDocument legacyDocument = QJsonDocument::fromJson(legacyJson.readAll(), &legacyParseError);
if (legacyParseError.error != QJsonParseError::NoError || !legacyDocument.isObject())
{
qWarning().noquote() << QStringLiteral("Cannot parse legacy JSON state '%1' at offset %2: %3")
.arg(legacyJsonPath)
.arg(legacyParseError.offset)
.arg(legacyParseError.errorString());
}
else
{
const QJsonObject legacy = legacyDocument.object();
for (const QString& key : legacyMigratableKeys())
{
const QJsonValue value = legacy.value(key);
if (value.isString())
jsonValues.insert(key, value.toString());
}
}
}
}
QStringList migratedKeys;
for (const QString& key : legacyMigratableKeys())
{
const QString storageKey = stateStorageKey(key);
if (settings.contains(storageKey))
continue;
QString normalizedValue;
bool hasValue = false;
if (jsonValues.contains(key))
{
hasValue = normalizeMutableStateValue(key, jsonValues.value(key).toString(), &normalizedValue);
if (!hasValue)
qWarning().noquote() << QStringLiteral("Rejected invalid legacy JSON state key '%1' from '%2'.")
.arg(key, legacyJsonPath);
}
if (!hasValue && iniValues.contains(key))
{
hasValue = normalizeMutableStateValue(key, iniValues.value(key).toString(), &normalizedValue);
if (!hasValue)
qWarning().noquote() << QStringLiteral("Rejected invalid legacy INI state key '%1' from '%2'.")
.arg(key, legacyIniPath);
}
if (hasValue)
{
settings.setValue(storageKey, normalizedValue);
migratedKeys.append(key);
}
}
settings.sync();
if (settings.status() != QSettings::NoError)
{
m_error = QStringLiteral("Cannot persist legacy runtime state migration in system settings: %1")
.arg(settingsStatusText(settings.status()));
return false;
}
settings.setValue(markerKey, kMigrationVersion);
settings.sync();
if (settings.status() != QSettings::NoError)
{
m_error = QStringLiteral("Cannot mark legacy runtime state migration complete in system settings: %1")
.arg(settingsStatusText(settings.status()));
return false;
}
if (!migratedKeys.isEmpty())
qInfo().noquote() << QStringLiteral("Migrated legacy runtime state keys: %1")
.arg(migratedKeys.join(QStringLiteral(", ")));
return true;
}
bool ConfigHelper::initializeStateDefaults()
{
QSettings settings(QSettings::NativeFormat, QSettings::SystemScope,
QString::fromLatin1(kSettingsOrganization),
QString::fromLatin1(kSettingsApplication));
settings.setFallbacksEnabled(false);
for (const QString& key : mutableKeys())
{
const QString storageKey = stateStorageKey(key);
if (!settings.contains(storageKey) && m_initialState.contains(key))
settings.setValue(storageKey, m_initialState.value(key).toString());
}
settings.sync();
if (settings.status() != QSettings::NoError)
{
m_error = QStringLiteral("Cannot initialize runtime state in system settings: %1")
.arg(settingsStatusText(settings.status()));
return false;
}
return true;
}
bool ConfigHelper::migrateLegacyIniIfNeeded()
QString ConfigHelper::stateStorageKey(const QString& key) const
{
if (QFile::exists(m_configPath))
return true;
const QString legacyPath = QApplication::applicationDirPath() + "/client.ini";
if (!QFile::exists(legacyPath))
return false;
QSettings ini(legacyPath, QSettings::IniFormat);
QJsonObject config;
const auto copyText = [&](const QString& section, const QString& key, const QString& fallback = QString()) {
const QString value = ini.value(section + "/" + key, fallback).toString();
config.insert(key, value);
};
copyText("App", "app_id");
copyText("App", "app_name", "Marsco Demo App");
copyText("App", "channel", "stable");
copyText("App", "current_version", "1.0.0");
copyText("App", "client_protocol", "3");
copyText("App", "launch_token");
copyText("License", "license_key");
copyText("Server", "api_base_url");
copyText("Server", "client_token");
copyText("Update", "request_timeout_ms", "5000");
copyText("Update", "temp_folder", "update_temp");
copyText("Update", "device_id");
copyText("Runtime", "install_root", ".");
copyText("Runtime", "main_executable", "MainApp.exe");
copyText("Runtime", "launcher_executable", "Launcher.exe");
copyText("Runtime", "updater_executable", "Updater.exe");
copyText("Runtime", "bootstrap_executable", "Bootstrap.exe");
copyText("Runtime", "health_check_timeout_ms", "15000");
config.insert("platform", "windows");
config.insert("arch", "x64");
QDir().mkpath(QFileInfo(m_configPath).path());
QSaveFile output(m_configPath);
if (!output.open(QIODevice::WriteOnly))
return false;
output.write(QJsonDocument(config).toJson(QJsonDocument::Indented));
const bool saved = output.commit();
if (saved)
qDebug() << "Migrated legacy client.ini to" << m_configPath;
return saved;
return m_statePrefix + QStringLiteral("state/") + key;
}
QString ConfigHelper::stateValue(const QString& key) const
{
if (m_statePrefix.isEmpty())
return QString();
QSettings settings(QSettings::NativeFormat, QSettings::SystemScope,
QString::fromLatin1(kSettingsOrganization),
QString::fromLatin1(kSettingsApplication));
settings.setFallbacksEnabled(false);
const QString storageKey = stateStorageKey(key);
if (settings.contains(storageKey))
return settings.value(storageKey).toString();
return QString();
}
bool ConfigHelper::writeStateValue(const QString& key, const QString& value)
{
QSettings settings(QSettings::NativeFormat, QSettings::SystemScope,
QString::fromLatin1(kSettingsOrganization),
QString::fromLatin1(kSettingsApplication));
settings.setFallbacksEnabled(false);
settings.setValue(stateStorageKey(key), value);
settings.sync();
if (settings.status() != QSettings::NoError)
{
m_error = QStringLiteral("Cannot write runtime state key '%1' to system settings: %2")
.arg(key, settingsStatusText(settings.status()));
return false;
}
return true;
}
bool ConfigHelper::isMutableKey(const QString& key)
{
return mutableKeys().contains(key);
}
+15 -2
View File
@@ -1,4 +1,5 @@
#pragma once
#include <QJsonObject>
#include <QString>
class ConfigHelper
@@ -8,6 +9,8 @@ public:
QString getValue(const QString& section, const QString& key) const;
bool setValue(const QString& section, const QString& key, const QString& value);
QString configPath() const;
QString productConfigPath() const;
QString stateLocation() const;
QString installRoot() const;
QString runtimeRoot() const;
QString updateRoot() const;
@@ -16,7 +19,17 @@ public:
private:
ConfigHelper();
bool migrateLegacyIniIfNeeded();
QString m_configPath;
bool loadProductConfig();
bool migrateLegacyStateIfNeeded();
bool initializeStateDefaults();
QString stateStorageKey(const QString& key) const;
QString stateValue(const QString& key) const;
bool writeStateValue(const QString& key, const QString& value);
static bool isMutableKey(const QString& key);
QString m_productConfigPath;
QString m_statePrefix;
QJsonObject m_productConfig;
QJsonObject m_initialState;
QString m_error;
};
+341 -24
View File
@@ -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;
}
+2 -2
View File
@@ -15,7 +15,7 @@ bool FileHelper::copyFileOverwrite(const QString &src, const QString &dst)
{
if (!QFile::remove(dst))
{
qDebug() << "无法删除旧文件:" << dst;
qDebug() << "Cannot remove the old file:" << dst;
return false;
}
}
@@ -39,4 +39,4 @@ bool FileHelper::killProcess(const QString &exeName)
process.start("taskkill /f /im " + exeName);
process.waitForFinished(1000);
return !isProcessRunning(exeName);
}
}
+2 -2
View File
@@ -11,7 +11,7 @@
class HttpHelper
{
public:
// 每次调用独立创建manager,不做成成员变量
// Each request owns its manager so synchronous calls cannot share reply state.
static void postRequest(const QString& url, const QJsonObject& jsonBody,
std::function<void(int code, const QJsonObject& resp)> callback);
};
};
+4 -7
View File
@@ -30,13 +30,13 @@ bool IntegrityHelper::runtimeProtectedPath(const QString& path) const
{
const QString p = QDir::fromNativeSeparators(path).toCaseFolded();
QSet<QString> protectedPaths{
"bootstrap.exe", "client.ini", "config/app_config.json", "config/local_state.json",
"client.ini", "config/app_config.json", "config/local_state.json",
"config/client_identity.dat", "config/version_policy.dat"
};
const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded();
if (!runtimePrefix.isEmpty()) {
const QStringList runtimeProtected{
"bootstrap.exe", "client.ini", "config/app_config.json", "config/local_state.json",
"client.ini", "config/app_config.json", "config/local_state.json",
"config/client_identity.dat", "config/version_policy.dat"
};
for (const QString& protectedPath : runtimeProtected)
@@ -59,11 +59,8 @@ bool IntegrityHelper::verifySignature(const QByteArray& payload, const QString&
#ifndef HAVE_OPENSSL
Q_UNUSED(payload); Q_UNUSED(signatureBase64); m_error = "OpenSSL unavailable"; return false;
#else
QString keyPath = QDir(m_installDir).filePath("config/manifest_public_key.pem");
if (!QFile::exists(keyPath))
keyPath = QFileInfo(ConfigHelper::instance().configPath()).dir().filePath("manifest_public_key.pem");
QFile keyFile(keyPath);
if (!keyFile.open(QIODevice::ReadOnly)) { m_error = "manifest public key missing"; return false; }
QFile keyFile(QStringLiteral(":/simcae/update-client/manifest-public-key.pem"));
if (!keyFile.open(QIODevice::ReadOnly)) { m_error = "embedded manifest 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;
+46 -6
View File
@@ -22,12 +22,35 @@ bool PolicyHelper::loadPolicy(const QString& relativePath)
if (error.error != QJsonParseError::NoError || !doc.isObject()) {
m_error = "Invalid policy JSON: " + error.errorString(); return false;
}
return loadPolicyObject(doc.object());
const QJsonObject stored = doc.object();
if (stored.value("policy").isObject() && stored.value("policy_text").isString())
return loadPolicyObject(stored.value("policy").toObject(),
stored.value("policy_text").toString());
return loadPolicyObject(stored);
}
bool PolicyHelper::loadPolicyObject(const QJsonObject& policy, const QString& signedText)
{
m_policy = policy; m_signedText = signedText; m_loaded = true; m_error.clear();
m_error.clear();
m_signedText = signedText;
m_policy = policy;
if (!signedText.isEmpty())
{
QJsonParseError parseError;
const QJsonDocument signedDocument =
QJsonDocument::fromJson(signedText.toUtf8(), &parseError);
if (parseError.error != QJsonParseError::NoError || !signedDocument.isObject())
{
m_error = "Signed policy text is invalid JSON: " + parseError.errorString();
m_loaded = false;
return false;
}
const QString signature = policy.value("signature").toString();
m_policy = signedDocument.object();
m_policy.insert("signature", signature);
}
m_loaded = true;
return isValid();
}
@@ -35,7 +58,12 @@ bool PolicyHelper::savePolicy(const QString& relativePath) const
{
QSaveFile file(m_baseDir + "/" + relativePath);
if (!file.open(QIODevice::WriteOnly)) return false;
const QByteArray bytes = QJsonDocument(m_policy).toJson(QJsonDocument::Indented);
QJsonObject stored = m_policy;
if (!m_signedText.isEmpty())
{
stored = QJsonObject{{"policy", m_policy}, {"policy_text", m_signedText}};
}
const QByteArray bytes = QJsonDocument(stored).toJson(QJsonDocument::Indented);
return file.write(bytes) == bytes.size() && file.commit();
}
@@ -68,9 +96,8 @@ bool PolicyHelper::verifySignature(const QByteArray& payload, const QString& sig
#ifndef HAVE_OPENSSL
Q_UNUSED(payload); Q_UNUSED(signatureBase64); m_error = "OpenSSL unavailable"; return false;
#else
QString keyPath = m_baseDir + "/config/manifest_public_key.pem";
QFile keyFile(keyPath);
if (!keyFile.open(QIODevice::ReadOnly)) { m_error = "Cannot open policy public key"; return false; }
QFile keyFile(QStringLiteral(":/simcae/update-client/manifest-public-key.pem"));
if (!keyFile.open(QIODevice::ReadOnly)) { m_error = "Cannot open embedded policy public key"; 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;
@@ -97,6 +124,19 @@ bool PolicyHelper::isValid() const
const QByteArray payload = m_signedText.isEmpty() ? canonicalPolicyBytes(m_policy) : m_signedText.toUtf8();
return verifySignature(payload, m_policy.value("signature").toString());
}
bool PolicyHelper::isApplicable(const QString& appId, const QString& channel,
const QString& currentVersion) const
{
if (!isValid()) return false;
if (m_policy.value("app_id").toString() != appId
|| m_policy.value("channel").toString() != channel
|| m_policy.value("current_version").toString() != currentVersion)
{
m_error = "Policy identity does not match this installation";
return false;
}
return true;
}
QString PolicyHelper::errorString() const { return m_error; }
bool PolicyHelper::isVersionAllowed(const QString& version) const {
if (!isValid()) return false;
+2
View File
@@ -11,6 +11,8 @@ public:
bool loadPolicyObject(const QJsonObject& policy, const QString& signedText = QString());
bool savePolicy(const QString& relativePath = "config/version_policy.dat") const;
bool isValid() const;
bool isApplicable(const QString& appId, const QString& channel,
const QString& currentVersion) const;
QString errorString() const;
bool isVersionAllowed(const QString& currentVersion) const;
bool allowRun() const;
+58 -5
View File
@@ -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);
}
+53
View File
@@ -0,0 +1,53 @@
#include "TranslationHelper.h"
#include <QCoreApplication>
#include <QDir>
namespace
{
constexpr auto kSimplifiedChineseCatalog =
":/simcae/update-client/i18n/update-client_zh_CN.qm";
constexpr auto kQtSimplifiedChineseCatalog = "translations/qt_zh_CN.qm";
bool usesSimplifiedChinese(const QLocale& locale)
{
if (locale.language() != QLocale::Chinese ||
locale.script() == QLocale::TraditionalHanScript)
{
return false;
}
return locale.script() == QLocale::SimplifiedHanScript ||
locale.country() == QLocale::China ||
locale.country() == QLocale::Singapore;
}
}
TranslationHelper::TranslationHelper()
{
m_simplifiedChineseCatalog.load(
QString::fromLatin1(kSimplifiedChineseCatalog));
}
bool TranslationHelper::installForSystemLocale()
{
if (!usesSimplifiedChinese(QLocale::system()) ||
m_simplifiedChineseCatalog.isEmpty())
{
return false;
}
const QString qtCatalogPath = QDir(QCoreApplication::applicationDirPath())
.filePath(QString::fromLatin1(
kQtSimplifiedChineseCatalog));
if (m_qtSimplifiedChineseCatalog.load(qtCatalogPath))
QCoreApplication::installTranslator(&m_qtSimplifiedChineseCatalog);
return QCoreApplication::installTranslator(&m_simplifiedChineseCatalog);
}
QString TranslationHelper::translate(const char* context,
const char* sourceText) const
{
return m_simplifiedChineseCatalog.translate(context, sourceText);
}
+18
View File
@@ -0,0 +1,18 @@
#pragma once
#include <QLocale>
#include <QString>
#include <QTranslator>
class TranslationHelper final
{
public:
TranslationHelper();
bool installForSystemLocale();
QString translate(const char* context, const char* sourceText) const;
private:
QTranslator m_qtSimplifiedChineseCatalog;
QTranslator m_simplifiedChineseCatalog;
};