278 lines
14 KiB
C++
278 lines
14 KiB
C++
#include "IntegrityHelper.h"
|
|
#include "ConfigHelper.h"
|
|
#include "UpdatePathPolicy.h"
|
|
#include <QCryptographicHash>
|
|
#include <QDir>
|
|
#include <QDirIterator>
|
|
#include <QFile>
|
|
#include <QFileInfo>
|
|
#include <QJsonArray>
|
|
#include <QCoreApplication>
|
|
#include <QJsonDocument>
|
|
#include <QJsonObject>
|
|
#include <QSet>
|
|
#ifdef HAVE_OPENSSL
|
|
#include <openssl/evp.h>
|
|
#include <openssl/pem.h>
|
|
#endif
|
|
|
|
namespace {
|
|
|
|
QString configValue(const QString& key, const QString& fallback = QString())
|
|
{
|
|
const QString value = ConfigHelper::instance().getValue(QString(), key).trimmed();
|
|
return value.isEmpty() ? fallback : value;
|
|
}
|
|
|
|
bool configFlag(const QString& key)
|
|
{
|
|
const QString value = configValue(key).toLower();
|
|
return value == QStringLiteral("true")
|
|
|| value == QStringLiteral("1")
|
|
|| value == QStringLiteral("yes")
|
|
|| value == QStringLiteral("on");
|
|
}
|
|
|
|
bool manifestFileRequired(const QJsonObject& item)
|
|
{
|
|
if (!item.contains(QStringLiteral("required")))
|
|
return true;
|
|
return item.value(QStringLiteral("required")).toBool(true);
|
|
}
|
|
|
|
} // namespace
|
|
|
|
IntegrityHelper::IntegrityHelper(const QString& installDir)
|
|
: m_installDir(QDir::cleanPath(installDir)) {}
|
|
|
|
QString IntegrityHelper::errorString() const { return m_error; }
|
|
|
|
bool IntegrityHelper::safeRelativePath(const QString& path) const
|
|
{
|
|
return UpdatePathPolicy::isSafeRelativePath(path);
|
|
}
|
|
|
|
bool IntegrityHelper::runtimeProtectedPath(const QString& path) const
|
|
{
|
|
return UpdatePathPolicy::isFullUpdateProtectedPath(
|
|
path, ConfigHelper::instance().runtimeRelativePath());
|
|
}
|
|
|
|
QString IntegrityHelper::sha256(const QString& filePath) const
|
|
{
|
|
QFile file(filePath);
|
|
if (!file.open(QIODevice::ReadOnly)) return {};
|
|
QCryptographicHash hash(QCryptographicHash::Sha256);
|
|
while (!file.atEnd()) hash.addData(file.read(1024 * 1024));
|
|
return QString::fromLatin1(hash.result().toHex());
|
|
}
|
|
|
|
bool IntegrityHelper::verifySignature(const QByteArray& payload, const QString& signatureBase64)
|
|
{
|
|
#ifndef HAVE_OPENSSL
|
|
Q_UNUSED(payload); Q_UNUSED(signatureBase64);
|
|
m_error = QCoreApplication::translate("IntegrityHelper",
|
|
"Cannot verify signed manifest because OpenSSL support is unavailable. Stage: installed version verification.");
|
|
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 = QCoreApplication::translate("IntegrityHelper",
|
|
"Cannot open manifest public key. Stage: installed version verification. Public key path: %1.")
|
|
.arg(keyPath);
|
|
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 = QCoreApplication::translate("IntegrityHelper",
|
|
"Manifest public key is invalid. Stage: installed version verification. Public key path: %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, key) == 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(key);
|
|
if (!ok) {
|
|
m_error = QCoreApplication::translate("IntegrityHelper",
|
|
"Manifest RSA signature is invalid. Stage: installed version verification. This usually means the cached manifest was changed, the client public key does not match the server private key, or the wrong version cache is being used.");
|
|
}
|
|
return ok;
|
|
#endif
|
|
}
|
|
|
|
bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString& channel,
|
|
const QString& version)
|
|
{
|
|
m_error.clear();
|
|
// Manifest cache 来自服务端发布版本时生成的签名清单。
|
|
// 客户端先验签 Manifest,再逐个校验文件 SHA256,防止升级文件被篡改或漏替换。
|
|
QString cachePath = QDir(ConfigHelper::instance().updateRoot()).filePath(
|
|
"manifest_cache/manifest_" + version + ".json");
|
|
const QString legacyCachePath = QDir(m_installDir).filePath(
|
|
"update/manifest_cache/manifest_" + version + ".json");
|
|
if (!QFile::exists(cachePath))
|
|
cachePath = legacyCachePath;
|
|
QFile cache(cachePath);
|
|
if (!cache.open(QIODevice::ReadOnly)) {
|
|
m_error = QCoreApplication::translate("IntegrityHelper",
|
|
"Local signed manifest cache is missing. Stage: installed version verification. Version: %1. Expected cache file: %2. This cache is created after the same version is published or installed successfully.")
|
|
.arg(version, cachePath);
|
|
return false;
|
|
}
|
|
QJsonParseError wrapperError;
|
|
const QJsonDocument wrapperDoc = QJsonDocument::fromJson(cache.readAll(), &wrapperError);
|
|
if (wrapperError.error != QJsonParseError::NoError || !wrapperDoc.isObject()) {
|
|
m_error = QCoreApplication::translate("IntegrityHelper",
|
|
"Local signed manifest cache is not valid JSON. Stage: installed version verification. Version: %1. File: %2. JSON error: %3.")
|
|
.arg(version, cachePath, wrapperError.errorString());
|
|
return false;
|
|
}
|
|
const QJsonObject wrapper = wrapperDoc.object();
|
|
QByteArray manifestText = wrapper.value("manifestText").toString().toUtf8();
|
|
if (manifestText.isEmpty())
|
|
manifestText = wrapper.value("manifest_text").toString().toUtf8();
|
|
const QString manifestSha256 = wrapper.value("manifestSha256").toString(
|
|
wrapper.value("manifest_sha256").toString());
|
|
const QString signature = wrapper.value("signature").toString(
|
|
wrapper.value("manifest").toObject().value("signature").toString());
|
|
const bool signedManifest = wrapper.value("signed").toBool(!signature.isEmpty());
|
|
if (manifestText.isEmpty()) {
|
|
m_error = QCoreApplication::translate("IntegrityHelper",
|
|
"Local signed manifest cache is incomplete. Stage: installed version verification. Version: %1. File: %2.")
|
|
.arg(version, cachePath);
|
|
return false;
|
|
}
|
|
if (!manifestSha256.isEmpty()) {
|
|
const QString actualSha = QString::fromLatin1(
|
|
QCryptographicHash::hash(manifestText, QCryptographicHash::Sha256).toHex());
|
|
if (actualSha.compare(manifestSha256, Qt::CaseInsensitive) != 0) {
|
|
m_error = QCoreApplication::translate("IntegrityHelper",
|
|
"Local manifest SHA-256 does not match the cached envelope. Stage: installed version verification. Version: %1.\nExpected SHA-256: %2\nActual SHA-256: %3")
|
|
.arg(version, manifestSha256, actualSha);
|
|
return false;
|
|
}
|
|
}
|
|
if (signedManifest && !signature.isEmpty()) {
|
|
if (!verifySignature(manifestText, signature)) return false;
|
|
} else if (configFlag(QStringLiteral("require_manifest_signature"))) {
|
|
m_error = QCoreApplication::translate("IntegrityHelper",
|
|
"Local manifest cache is unsigned, but require_manifest_signature is enabled. Stage: installed version verification. Version: %1.")
|
|
.arg(version);
|
|
return false;
|
|
}
|
|
|
|
QJsonParseError manifestError;
|
|
const QJsonDocument manifestDoc = QJsonDocument::fromJson(manifestText, &manifestError);
|
|
if (manifestError.error != QJsonParseError::NoError || !manifestDoc.isObject()) {
|
|
m_error = QCoreApplication::translate("IntegrityHelper",
|
|
"Signed manifest payload is not valid JSON. Stage: installed version verification. Version: %1. File: %2. JSON error: %3.")
|
|
.arg(version, cachePath, manifestError.errorString());
|
|
return false;
|
|
}
|
|
const QJsonObject manifest = manifestDoc.object();
|
|
const QString manifestProduct = manifest.value("productCode").toString(
|
|
manifest.value("app_id").toString());
|
|
if (manifestProduct != appId
|
|
|| manifest.value("channel").toString() != channel
|
|
|| manifest.value("version").toString() != version) {
|
|
m_error = QCoreApplication::translate("IntegrityHelper",
|
|
"Local signed manifest identity does not match this application. Stage: installed version verification. Expected product/channel/version: %1 / %2 / %3. Manifest product/channel/version: %4 / %5 / %6.")
|
|
.arg(appId, channel, version,
|
|
manifestProduct,
|
|
manifest.value("channel").toString(),
|
|
manifest.value("version").toString());
|
|
return false;
|
|
}
|
|
|
|
QSet<QString> declaredExecutables;
|
|
QSet<QString> optionalComponentDirs;
|
|
for (const QJsonValue& value : manifest.value("files").toArray()) {
|
|
const QJsonObject item = value.toObject();
|
|
const QString path = QDir::fromNativeSeparators(item.value("path").toString());
|
|
if (!safeRelativePath(path)) {
|
|
m_error = QCoreApplication::translate("IntegrityHelper",
|
|
"Signed manifest contains an unsafe file path. Stage: installed version verification. Version: %1. Path: %2.")
|
|
.arg(version, path);
|
|
return false;
|
|
}
|
|
if (UpdatePathPolicy::isExecutableOrLibrary(path))
|
|
declaredExecutables.insert(path.toCaseFolded());
|
|
if (!manifestFileRequired(item)) {
|
|
const QString dir = QDir::fromNativeSeparators(QFileInfo(path).path());
|
|
if (!dir.isEmpty() && dir != QStringLiteral("."))
|
|
optionalComponentDirs.insert((dir + QStringLiteral("/")).toCaseFolded());
|
|
continue;
|
|
}
|
|
if (runtimeProtectedPath(path)) continue;
|
|
const QString fullPath = QDir(m_installDir).filePath(path);
|
|
if (!QFile::exists(fullPath)) {
|
|
m_error = QCoreApplication::translate("IntegrityHelper",
|
|
"A required installed file is missing. Stage: installed version verification. Version: %1. Manifest path: %2. Checked path: %3. The local installation no longer matches the published version.")
|
|
.arg(version, path, fullPath);
|
|
return false;
|
|
}
|
|
const qint64 expectedSize = item.contains("sizeBytes")
|
|
? item.value("sizeBytes").toVariant().toLongLong()
|
|
: item.value("size").toVariant().toLongLong();
|
|
if ((item.contains("sizeBytes") || item.contains("size"))
|
|
&& QFileInfo(fullPath).size() != expectedSize) {
|
|
m_error = QCoreApplication::translate("IntegrityHelper",
|
|
"Installed file size does not match the local manifest. Stage: installed version verification. Version: %1. Manifest path: %2. Local path: %3.\nExpected size: %4 bytes\nActual size: %5 bytes")
|
|
.arg(version, path, fullPath,
|
|
QString::number(expectedSize), QString::number(QFileInfo(fullPath).size()));
|
|
return false;
|
|
}
|
|
const QString expected = item.value("sha256").toString();
|
|
const QString actual = sha256(fullPath);
|
|
if (actual.isEmpty() || actual.compare(expected, Qt::CaseInsensitive) != 0) {
|
|
m_error = QCoreApplication::translate("IntegrityHelper",
|
|
"Installed file SHA-256 does not match the local signed manifest. Stage: installed version verification. Version: %1. Manifest path: %2. Local path: %3.\nExpected SHA-256: %4\nActual SHA-256: %5\nThis means the installed file is different from the version that was published or installed. If this is a developer test machine, check whether the local Release directory was recompiled or overwritten after publishing.")
|
|
.arg(version, path, fullPath, expected,
|
|
actual.isEmpty() ? QCoreApplication::translate("IntegrityHelper", "<cannot read file>") : actual);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
QDir root(m_installDir);
|
|
QDirIterator it(m_installDir, QDir::Files, QDirIterator::Subdirectories);
|
|
// 除了清单中声明的文件,还要拒绝额外出现的 exe/dll。
|
|
// 这能降低被人偷偷塞插件或可执行文件的风险。
|
|
while (it.hasNext()) {
|
|
const QString fullPath = it.next();
|
|
const QString relative = QDir::fromNativeSeparators(root.relativeFilePath(fullPath));
|
|
const QString folded = relative.toCaseFolded();
|
|
const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded();
|
|
const bool runtimeWorkDir = !runtimePrefix.isEmpty()
|
|
&& (folded.startsWith(runtimePrefix + "/update/")
|
|
|| folded.startsWith(runtimePrefix + "/update_temp/"));
|
|
if (folded.startsWith("update/") || folded.startsWith("update_temp/")
|
|
|| runtimeWorkDir || runtimeProtectedPath(relative)) continue;
|
|
bool optionalComponentFile = false;
|
|
for (const QString& prefix : optionalComponentDirs) {
|
|
if (folded.startsWith(prefix)) {
|
|
optionalComponentFile = true;
|
|
break;
|
|
}
|
|
}
|
|
if (optionalComponentFile)
|
|
continue;
|
|
if (UpdatePathPolicy::isExecutableOrLibrary(relative) && !declaredExecutables.contains(folded)) {
|
|
m_error = QCoreApplication::translate("IntegrityHelper",
|
|
"An executable or DLL exists locally but is not declared in the signed manifest. Stage: installed version verification. Version: %1. Extra file: %2. Remove unexpected executable/plugin files or publish a new version that declares them.")
|
|
.arg(version, relative);
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|