#include "IntegrityHelper.h" #include "ConfigHelper.h" #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 { const QString p = QDir::fromNativeSeparators(path).toCaseFolded(); QSet protectedPaths{ "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{ "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 = "OpenSSL unavailable"; return false; #else 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; if (bio) BIO_free(bio); if (!key) { m_error = "manifest public key invalid"; 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 = "manifest RSA signature invalid"; return ok; #endif } bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString& channel, const QString& version) { m_error.clear(); 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 = "signed manifest cache missing for " + version; return false; } QJsonParseError wrapperError; const QJsonDocument wrapperDoc = QJsonDocument::fromJson(cache.readAll(), &wrapperError); if (wrapperError.error != QJsonParseError::NoError || !wrapperDoc.isObject()) { m_error = "manifest cache JSON invalid"; 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() || !verifySignature(manifestText, signature)) return false; QJsonParseError manifestError; const QJsonDocument manifestDoc = QJsonDocument::fromJson(manifestText, &manifestError); if (manifestError.error != QJsonParseError::NoError || !manifestDoc.isObject()) { m_error = "signed manifest payload invalid"; 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 = "manifest identity does not match local application"; 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 = "unsafe manifest path: " + path; return false; } if (runtimeProtectedPath(path)) continue; const QString fullPath = QDir(m_installDir).filePath(path); if (!QFile::exists(fullPath)) { m_error = "required file missing: " + path; 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 = "file hash mismatch: " + path; 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); 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 = "undeclared executable or plugin: " + relative; return false; } } return true; }