#include "IntegrityHelper.h" #include "ConfigHelper.h" #include "UpdatePathPolicy.h" #include #include #include #include #include #include #include #include #include #include #ifdef HAVE_OPENSSL #include #include #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(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 declaredExecutables; QSet 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", "") : 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; }