#include "IntegrityHelper.h" #include "ConfigHelper.h" #include #include #include #include #include #include #include #include #include #include #ifdef HAVE_OPENSSL #include #include #endif IntegrityHelper::IntegrityHelper(const QString& installDir) : m_installDir(QDir::cleanPath(installDir)) {} QString IntegrityHelper::errorString() const { return m_error; } bool IntegrityHelper::safeRelativePath(const QString& path) const { const QString clean = QDir::cleanPath(QDir::fromNativeSeparators(path)); return !clean.isEmpty() && !QDir::isAbsolutePath(clean) && clean != ".." && !clean.startsWith("../") && !clean.contains(":"); } bool IntegrityHelper::runtimeProtectedPath(const QString& path) const { // 这些文件属于 SDK 运行态,不参与业务版本文件的 Manifest 校验。 // 例如 app_config.json、client_identity.dat 会随安装机器变化,不能要求它们和发布包 hash 完全一致。 const QString p = QDir::fromNativeSeparators(path).toCaseFolded(); QSet protectedPaths{ "bootstrap", "bootstrap.exe", "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", "bootstrap.exe", "client.ini", "config/app_config.json", "config/local_state.json", "config/client_identity.dat", "config/version_policy.dat" }; for (const QString& protectedPath : runtimeProtected) protectedPaths.insert(runtimePrefix + "/" + protectedPath); } return protectedPaths.contains(p); } 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(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(); const QByteArray manifestText = wrapper.value("manifest_text").toString().toUtf8(); const QString signature = wrapper.value("manifest").toObject().value("signature").toString(); if (manifestText.isEmpty() || signature.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 (!verifySignature(manifestText, signature)) 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(); if (manifest.value("app_id").toString() != 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 app/channel/version: %1 / %2 / %3. Manifest app/channel/version: %4 / %5 / %6.") .arg(appId, channel, version, manifest.value("app_id").toString(), manifest.value("channel").toString(), manifest.value("version").toString()); return false; } QSet declaredExecutables; 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 (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 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", "") : actual); return false; } const QString suffix = QFileInfo(path).suffix().toCaseFolded(); if (suffix == "exe" || suffix == "dll") declaredExecutables.insert(path.toCaseFolded()); } 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; const QString suffix = QFileInfo(relative).suffix().toCaseFolded(); if ((suffix == "exe" || suffix == "dll") && !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; }