2026-07-09 09:17:30 +00:00
|
|
|
#include "IntegrityHelper.h"
|
|
|
|
|
#include "ConfigHelper.h"
|
|
|
|
|
#include <QCryptographicHash>
|
|
|
|
|
#include <QDir>
|
|
|
|
|
#include <QDirIterator>
|
|
|
|
|
#include <QFile>
|
|
|
|
|
#include <QFileInfo>
|
|
|
|
|
#include <QJsonArray>
|
|
|
|
|
#include <QJsonDocument>
|
|
|
|
|
#include <QJsonObject>
|
|
|
|
|
#include <QSet>
|
|
|
|
|
#ifdef HAVE_OPENSSL
|
|
|
|
|
#include <openssl/evp.h>
|
|
|
|
|
#include <openssl/pem.h>
|
|
|
|
|
#endif
|
|
|
|
|
|
|
|
|
|
IntegrityHelper::IntegrityHelper(const QString& installDir)
|
|
|
|
|
: m_installDir(QDir::cleanPath(installDir)) {}
|
|
|
|
|
|
|
|
|
|
QString IntegrityHelper::errorString() const { return m_error; }
|
|
|
|
|
|
2026-07-10 02:38:04 -07:00
|
|
|
bool IntegrityHelper::isManifestCacheMissing() const
|
|
|
|
|
{
|
|
|
|
|
return m_manifestCacheMissing;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-09 09:17:30 +00:00
|
|
|
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<QString> protectedPaths{
|
2026-07-10 00:38:23 -07:00
|
|
|
"client.ini", "config/app_config.json", "config/local_state.json",
|
2026-07-09 09:17:30 +00:00
|
|
|
"config/client_identity.dat", "config/version_policy.dat"
|
|
|
|
|
};
|
|
|
|
|
const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded();
|
|
|
|
|
if (!runtimePrefix.isEmpty()) {
|
|
|
|
|
const QStringList runtimeProtected{
|
2026-07-10 00:38:23 -07:00
|
|
|
"client.ini", "config/app_config.json", "config/local_state.json",
|
2026-07-09 09:17:30 +00:00
|
|
|
"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
|
2026-07-10 00:38:23 -07:00
|
|
|
QFile keyFile(QStringLiteral(":/simcae/update-client/manifest-public-key.pem"));
|
|
|
|
|
if (!keyFile.open(QIODevice::ReadOnly)) { m_error = "embedded manifest public key missing"; return false; }
|
2026-07-09 09:17:30 +00:00
|
|
|
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<const unsigned char*>(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();
|
2026-07-10 02:38:04 -07:00
|
|
|
m_manifestCacheMissing = false;
|
2026-07-09 09:17:30 +00:00
|
|
|
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;
|
2026-07-10 02:38:04 -07:00
|
|
|
if (!QFile::exists(cachePath)) {
|
|
|
|
|
m_manifestCacheMissing = true;
|
|
|
|
|
m_error = "signed manifest cache missing for " + version;
|
|
|
|
|
return false;
|
|
|
|
|
}
|
2026-07-09 09:17:30 +00:00
|
|
|
QFile cache(cachePath);
|
2026-07-10 02:38:04 -07:00
|
|
|
if (!cache.open(QIODevice::ReadOnly)) { m_error = "cannot read signed manifest cache for " + version; return false; }
|
2026-07-09 09:17:30 +00:00
|
|
|
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<QString> 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;
|
|
|
|
|
}
|