fix: 优化客户端错误提示和校验诊断信息
This commit is contained in:
@@ -228,7 +228,7 @@ bool runElevatedSelfCommand(const QStringList& arguments, const QString& targetP
|
||||
const QMessageBox::StandardButton choice = QMessageBox::question(
|
||||
nullptr,
|
||||
QCoreApplication::translate("ConfigHelper", "Administrator Permission Required"),
|
||||
QCoreApplication::translate("ConfigHelper", "The current installation directory requires administrator permission to save configuration.\n\nTarget file: %1\nReason: %2\n\nClick OK, then choose Yes in the Windows permission confirmation dialog.")
|
||||
QCoreApplication::translate("ConfigHelper", "The current operation needs administrator permission to modify a protected file.\n\nTarget file: %1\nReason: %2\n\nClick OK, then choose Yes in the Windows permission confirmation dialog.")
|
||||
.arg(QDir::toNativeSeparators(targetPath), originalError),
|
||||
QMessageBox::Ok | QMessageBox::Cancel,
|
||||
QMessageBox::Ok);
|
||||
|
||||
@@ -257,12 +257,34 @@ bool DeviceIdentityHelper::ensureIssued(
|
||||
timer.stop();
|
||||
|
||||
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
||||
const QString networkError = reply->errorString();
|
||||
const QByteArray raw = reply->readAll();
|
||||
reply->deleteLater();
|
||||
if (status != 200) {
|
||||
m_error = QCoreApplication::translate("DeviceIdentityHelper", "Device identity request failed (HTTP %1): %2")
|
||||
.arg(status)
|
||||
.arg(QString::fromUtf8(raw));
|
||||
QJsonParseError responseError;
|
||||
const QJsonDocument errorDoc = QJsonDocument::fromJson(raw, &responseError);
|
||||
QString serverMessage;
|
||||
if (responseError.error == QJsonParseError::NoError && errorDoc.isObject()) {
|
||||
const QJsonValue detail = errorDoc.object().value(QStringLiteral("detail"));
|
||||
serverMessage = detail.isObject()
|
||||
? detail.toObject().value(QStringLiteral("msg")).toString()
|
||||
: detail.toString();
|
||||
}
|
||||
if (serverMessage.isEmpty())
|
||||
serverMessage = QString::fromUtf8(raw).trimmed();
|
||||
|
||||
if (status == 0) {
|
||||
m_error = QCoreApplication::translate(
|
||||
"DeviceIdentityHelper",
|
||||
"Cannot contact the update server to issue device identity.\nServer: %1\nApp: %2\nChannel: %3\nNetwork error: %4\nTimeout: %5 ms")
|
||||
.arg(trimmedBaseUrl, appId, channel, networkError, QString::number(timeoutMs));
|
||||
} else {
|
||||
m_error = QCoreApplication::translate(
|
||||
"DeviceIdentityHelper",
|
||||
"Device identity request was rejected by the update server.\nServer: %1\nHTTP status: %2\nApp: %3\nChannel: %4\nServer message: %5")
|
||||
.arg(trimmedBaseUrl, QString::number(status), appId, channel,
|
||||
serverMessage.isEmpty() ? QCoreApplication::translate("DeviceIdentityHelper", "<empty response>") : serverMessage);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
+100
-43
@@ -5,8 +5,9 @@
|
||||
#include <QDirIterator>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonArray>
|
||||
#include <QCoreApplication>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QSet>
|
||||
#ifdef HAVE_OPENSSL
|
||||
@@ -58,19 +59,32 @@ QString IntegrityHelper::sha256(const QString& filePath) const
|
||||
|
||||
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
|
||||
#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 = "manifest public key missing"; return false; }
|
||||
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 = "manifest public key invalid"; return false; }
|
||||
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
|
||||
@@ -78,10 +92,13 @@ bool IntegrityHelper::verifySignature(const QByteArray& payload, const QString&
|
||||
&& 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
|
||||
}
|
||||
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)
|
||||
@@ -96,42 +113,79 @@ bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString
|
||||
if (!QFile::exists(cachePath))
|
||||
cachePath = legacyCachePath;
|
||||
QFile cache(cachePath);
|
||||
if (!cache.open(QIODevice::ReadOnly)) { m_error = "signed manifest cache missing for " + version; return false; }
|
||||
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 = "manifest cache JSON invalid"; return false;
|
||||
}
|
||||
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() || !verifySignature(manifestText, signature)) return false;
|
||||
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 = "signed manifest payload invalid"; return false;
|
||||
}
|
||||
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 = "manifest identity does not match local application"; return false;
|
||||
}
|
||||
|| 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<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 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", "<cannot read file>") : actual);
|
||||
return false;
|
||||
}
|
||||
const QString suffix = QFileInfo(path).suffix().toCaseFolded();
|
||||
if (suffix == "exe" || suffix == "dll") declaredExecutables.insert(path.toCaseFolded());
|
||||
}
|
||||
@@ -150,10 +204,13 @@ bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString
|
||||
|| 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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
+33
-18
@@ -1,5 +1,6 @@
|
||||
#include "LocalStateHelper.h"
|
||||
#include "ConfigHelper.h"
|
||||
#include <QCoreApplication>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QJsonDocument>
|
||||
@@ -29,30 +30,39 @@ bool LocalStateHelper::loadState(const QString& relativePath)
|
||||
{"last_success_version", QString()}
|
||||
};
|
||||
m_loaded = true;
|
||||
if (!saveState())
|
||||
{
|
||||
m_error = QString("Cannot write new state file: %1").arg(m_filePath);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
if (!saveState())
|
||||
{
|
||||
m_error = QCoreApplication::translate(
|
||||
"LocalStateHelper",
|
||||
"Cannot create local state file: %1. Error: %2.")
|
||||
.arg(m_filePath, m_error);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!file.open(QIODevice::ReadOnly))
|
||||
{
|
||||
m_error = QString("Cannot open state file: %1").arg(m_filePath);
|
||||
return false;
|
||||
}
|
||||
if (!file.open(QIODevice::ReadOnly))
|
||||
{
|
||||
m_error = QCoreApplication::translate(
|
||||
"LocalStateHelper",
|
||||
"Cannot open local state file: %1. Error: %2.")
|
||||
.arg(m_filePath, file.errorString());
|
||||
return false;
|
||||
}
|
||||
|
||||
QByteArray raw = file.readAll();
|
||||
file.close();
|
||||
|
||||
QJsonParseError parseError;
|
||||
QJsonDocument doc = QJsonDocument::fromJson(raw, &parseError);
|
||||
if (parseError.error != QJsonParseError::NoError || !doc.isObject())
|
||||
{
|
||||
m_error = QString("Invalid state JSON: %1").arg(parseError.errorString());
|
||||
return false;
|
||||
}
|
||||
if (parseError.error != QJsonParseError::NoError || !doc.isObject())
|
||||
{
|
||||
m_error = QCoreApplication::translate(
|
||||
"LocalStateHelper",
|
||||
"Local state file is not valid JSON. File: %1. JSON error: %2.")
|
||||
.arg(m_filePath, parseError.errorString());
|
||||
return false;
|
||||
}
|
||||
|
||||
m_state = doc.object();
|
||||
m_loaded = true;
|
||||
@@ -63,7 +73,9 @@ bool LocalStateHelper::saveState() const
|
||||
{
|
||||
if (!m_loaded)
|
||||
{
|
||||
m_error = "State is not loaded";
|
||||
m_error = QCoreApplication::translate(
|
||||
"LocalStateHelper",
|
||||
"Local state has not been loaded.");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -71,7 +83,10 @@ bool LocalStateHelper::saveState() const
|
||||
QString writeError;
|
||||
if (!ConfigHelper::writeFileWithElevationIfNeeded(m_filePath, doc.toJson(QJsonDocument::Indented), &writeError))
|
||||
{
|
||||
m_error = QString("Cannot save state file: %1").arg(writeError);
|
||||
m_error = QCoreApplication::translate(
|
||||
"LocalStateHelper",
|
||||
"Cannot save local state file: %1. Error: %2.")
|
||||
.arg(m_filePath, writeError);
|
||||
return false;
|
||||
}
|
||||
m_error.clear();
|
||||
|
||||
+79
-26
@@ -1,6 +1,7 @@
|
||||
#include "PolicyHelper.h"
|
||||
#include "ConfigHelper.h"
|
||||
#include <QApplication>
|
||||
#include <QCoreApplication>
|
||||
#include <QDateTime>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
@@ -29,15 +30,26 @@ static QString resolvePolicyPath(const QString& baseDir, const QString& relative
|
||||
|
||||
bool PolicyHelper::loadPolicy(const QString& relativePath)
|
||||
{
|
||||
QFile file(resolvePolicyPath(m_baseDir, relativePath, false));
|
||||
if (!file.open(QIODevice::ReadOnly)) { m_error = "Cannot open policy file"; return false; }
|
||||
QJsonParseError error;
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &error);
|
||||
if (error.error != QJsonParseError::NoError || !doc.isObject()) {
|
||||
m_error = "Invalid policy JSON: " + error.errorString(); return false;
|
||||
}
|
||||
return loadPolicyObject(doc.object());
|
||||
}
|
||||
const QString path = resolvePolicyPath(m_baseDir, relativePath, false);
|
||||
QFile file(path);
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
m_error = QCoreApplication::translate(
|
||||
"PolicyHelper",
|
||||
"Cannot open signed version policy file: %1. Error: %2.")
|
||||
.arg(path, file.errorString());
|
||||
return false;
|
||||
}
|
||||
QJsonParseError error;
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &error);
|
||||
if (error.error != QJsonParseError::NoError || !doc.isObject()) {
|
||||
m_error = QCoreApplication::translate(
|
||||
"PolicyHelper",
|
||||
"Signed version policy is not valid JSON. File: %1. JSON error: %2.")
|
||||
.arg(path, error.errorString());
|
||||
return false;
|
||||
}
|
||||
return loadPolicyObject(doc.object());
|
||||
}
|
||||
|
||||
bool PolicyHelper::loadPolicyObject(const QJsonObject& policy, const QString& signedText)
|
||||
{
|
||||
@@ -51,7 +63,10 @@ bool PolicyHelper::savePolicy(const QString& relativePath) const
|
||||
QString writeError;
|
||||
const QString path = resolvePolicyPath(m_baseDir, relativePath, true);
|
||||
if (!ConfigHelper::writeFileWithElevationIfNeeded(path, bytes, &writeError)) {
|
||||
m_error = "Cannot save policy file: " + writeError;
|
||||
m_error = QCoreApplication::translate(
|
||||
"PolicyHelper",
|
||||
"Cannot save signed version policy file: %1. Error: %2.")
|
||||
.arg(path, writeError);
|
||||
return false;
|
||||
}
|
||||
m_error.clear();
|
||||
@@ -84,35 +99,73 @@ QByteArray PolicyHelper::canonicalPolicyBytes(const QJsonObject& policy) const
|
||||
|
||||
bool PolicyHelper::verifySignature(const QByteArray& payload, const QString& signatureBase64) const
|
||||
{
|
||||
#ifndef HAVE_OPENSSL
|
||||
Q_UNUSED(payload); Q_UNUSED(signatureBase64); m_error = "OpenSSL unavailable"; return false;
|
||||
#else
|
||||
QString keyPath = m_baseDir + "/config/manifest_public_key.pem";
|
||||
QFile keyFile(keyPath);
|
||||
if (!keyFile.open(QIODevice::ReadOnly)) { m_error = "Cannot open policy public key"; return false; }
|
||||
#ifndef HAVE_OPENSSL
|
||||
Q_UNUSED(payload);
|
||||
Q_UNUSED(signatureBase64);
|
||||
m_error = QCoreApplication::translate(
|
||||
"PolicyHelper",
|
||||
"OpenSSL is unavailable, so the signed version policy cannot be verified.");
|
||||
return false;
|
||||
#else
|
||||
QString keyPath = m_baseDir + "/config/manifest_public_key.pem";
|
||||
QFile keyFile(keyPath);
|
||||
if (!keyFile.open(QIODevice::ReadOnly)) {
|
||||
m_error = QCoreApplication::translate(
|
||||
"PolicyHelper",
|
||||
"Cannot open version policy public key: %1. Error: %2.")
|
||||
.arg(keyPath, keyFile.errorString());
|
||||
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 = "Invalid policy public key"; return false; }
|
||||
if (!key) {
|
||||
m_error = QCoreApplication::translate(
|
||||
"PolicyHelper",
|
||||
"Version policy public key is invalid: %1.")
|
||||
.arg(keyPath);
|
||||
return false;
|
||||
}
|
||||
EVP_MD_CTX* ctx = EVP_MD_CTX_new();
|
||||
const QByteArray signature = QByteArray::fromBase64(signatureBase64.toUtf8());
|
||||
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 = "Invalid RSA policy signature";
|
||||
return ok;
|
||||
#endif
|
||||
}
|
||||
if (!ok) {
|
||||
m_error = QCoreApplication::translate(
|
||||
"PolicyHelper",
|
||||
"Version policy RSA signature is invalid. The policy file may have been changed, or the public key does not match the server private key.");
|
||||
}
|
||||
return ok;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool PolicyHelper::isValid() const
|
||||
{
|
||||
if (!m_loaded) return false;
|
||||
const QStringList required{"app_id","channel","current_version","policy_seq","allow_run",
|
||||
"force_update","allow_rollback","offline_allowed","valid_until","signature_alg","key_id","signature"};
|
||||
for (const QString& key : required) if (!m_policy.contains(key)) { m_error = "Missing policy field: " + key; return false; }
|
||||
if (m_policy.value("signature_alg").toString() != "RSA-2048-SHA256") return false;
|
||||
if (!m_loaded) {
|
||||
m_error = QCoreApplication::translate("PolicyHelper", "Version policy has not been loaded.");
|
||||
return false;
|
||||
}
|
||||
const QStringList required{"app_id","channel","current_version","policy_seq","allow_run",
|
||||
"force_update","allow_rollback","offline_allowed","valid_until","signature_alg","key_id","signature"};
|
||||
for (const QString& key : required) {
|
||||
if (!m_policy.contains(key)) {
|
||||
m_error = QCoreApplication::translate(
|
||||
"PolicyHelper",
|
||||
"Version policy is missing required field: %1.")
|
||||
.arg(key);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (m_policy.value("signature_alg").toString() != "RSA-2048-SHA256") {
|
||||
m_error = QCoreApplication::translate(
|
||||
"PolicyHelper",
|
||||
"Version policy signature algorithm is unsupported: %1.")
|
||||
.arg(m_policy.value("signature_alg").toString());
|
||||
return false;
|
||||
}
|
||||
const QByteArray payload = m_signedText.isEmpty() ? canonicalPolicyBytes(m_policy) : m_signedText.toUtf8();
|
||||
return verifySignature(payload, m_policy.value("signature").toString());
|
||||
}
|
||||
|
||||
+106
-36
@@ -5,10 +5,11 @@
|
||||
#include <QFile>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QMessageAuthenticationCode>
|
||||
#include <QSaveFile>
|
||||
#include <QStandardPaths>
|
||||
#include <QUuid>
|
||||
#include <QMessageAuthenticationCode>
|
||||
#include <QSaveFile>
|
||||
#include <QStandardPaths>
|
||||
#include <QStringList>
|
||||
#include <QUuid>
|
||||
|
||||
static QByteArray ticketMac(const QJsonObject& payload, const QString& secret)
|
||||
{
|
||||
@@ -22,10 +23,20 @@ bool TicketHelper::createTicket(const QString& appId, const QString& deviceId,
|
||||
{
|
||||
// Launcher 启动业务主程序前生成一次性 ticket。
|
||||
// ticket 只保存在临时目录、有效期 60 秒,并用 launch_token 做 HMAC,防止用户绕过 Launcher 直接启动主程序。
|
||||
if (appId.isEmpty() || version.isEmpty() || secret.isEmpty()) {
|
||||
if (errorMessage) *errorMessage = "ticket identity or secret is empty";
|
||||
return false;
|
||||
}
|
||||
if (appId.isEmpty() || deviceId.isEmpty() || version.isEmpty() || secret.isEmpty()) {
|
||||
if (errorMessage) {
|
||||
QStringList missing;
|
||||
if (appId.isEmpty()) missing.append(QStringLiteral("app_id"));
|
||||
if (deviceId.isEmpty()) missing.append(QStringLiteral("device_id"));
|
||||
if (version.isEmpty()) missing.append(QStringLiteral("current_version"));
|
||||
if (secret.isEmpty()) missing.append(QStringLiteral("launch_token"));
|
||||
*errorMessage = QCoreApplication::translate(
|
||||
"TicketHelper",
|
||||
"Cannot create launch ticket because required fields are empty: %1. Check app_config.json, registry-imported configuration and device authorization.")
|
||||
.arg(missing.join(QStringLiteral(", ")));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const QDateTime now = QDateTime::currentDateTimeUtc();
|
||||
QJsonObject payload{
|
||||
{"app_id", appId}, {"device_id", deviceId}, {"version", version},
|
||||
@@ -36,14 +47,27 @@ bool TicketHelper::createTicket(const QString& appId, const QString& deviceId,
|
||||
};
|
||||
QJsonObject wrapper{{"payload", payload}, {"signature", QString::fromLatin1(ticketMac(payload, secret))}};
|
||||
const QString dirPath = QDir(QStandardPaths::writableLocation(QStandardPaths::TempLocation)).filePath("marsco_tickets");
|
||||
if (!QDir().mkpath(dirPath)) { if (errorMessage) *errorMessage = "cannot create ticket directory"; return false; }
|
||||
if (!QDir().mkpath(dirPath)) {
|
||||
if (errorMessage) {
|
||||
*errorMessage = QCoreApplication::translate(
|
||||
"TicketHelper",
|
||||
"Cannot create launch ticket directory: %1.")
|
||||
.arg(dirPath);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const QString path = QDir(dirPath).filePath("ticket_" + QUuid::createUuid().toString(QUuid::WithoutBraces) + ".json");
|
||||
QSaveFile file(path);
|
||||
const QByteArray bytes = QJsonDocument(wrapper).toJson(QJsonDocument::Compact);
|
||||
if (!file.open(QIODevice::WriteOnly) || file.write(bytes) != bytes.size() || !file.commit()) {
|
||||
if (errorMessage) *errorMessage = "cannot save ticket";
|
||||
return false;
|
||||
}
|
||||
QSaveFile file(path);
|
||||
const QByteArray bytes = QJsonDocument(wrapper).toJson(QJsonDocument::Compact);
|
||||
if (!file.open(QIODevice::WriteOnly) || file.write(bytes) != bytes.size() || !file.commit()) {
|
||||
if (errorMessage) {
|
||||
*errorMessage = QCoreApplication::translate(
|
||||
"TicketHelper",
|
||||
"Cannot save launch ticket file: %1. Error: %2.")
|
||||
.arg(path, file.errorString());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
QFile::setPermissions(path, QFileDevice::ReadOwner | QFileDevice::WriteOwner);
|
||||
if (ticketPath) *ticketPath = path;
|
||||
return true;
|
||||
@@ -56,24 +80,40 @@ bool TicketHelper::consumeAndVerify(const QString& ticketPath, const QString& ex
|
||||
// 主程序启动后第一时间把 ticket 改名成 .consuming,再读取并删除。
|
||||
// 这样同一张 ticket 即使校验失败也不能被重复使用,避免重放启动。
|
||||
const QString consumingPath = ticketPath + ".consuming."
|
||||
+ QString::number(QCoreApplication::applicationPid());
|
||||
if (!QFile::rename(ticketPath, consumingPath)) {
|
||||
if (errorMessage) *errorMessage = "ticket missing or already consumed";
|
||||
return false;
|
||||
}
|
||||
QFile file(consumingPath);
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
QFile::remove(consumingPath);
|
||||
if (errorMessage) *errorMessage = "cannot read claimed ticket";
|
||||
return false;
|
||||
}
|
||||
+ QString::number(QCoreApplication::applicationPid());
|
||||
if (!QFile::rename(ticketPath, consumingPath)) {
|
||||
if (errorMessage) {
|
||||
*errorMessage = QCoreApplication::translate(
|
||||
"TicketHelper",
|
||||
"Launch ticket is missing or has already been consumed. Ticket file: %1. Please start the application from Launcher.")
|
||||
.arg(ticketPath);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
QFile file(consumingPath);
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
QFile::remove(consumingPath);
|
||||
if (errorMessage) {
|
||||
*errorMessage = QCoreApplication::translate(
|
||||
"TicketHelper",
|
||||
"Cannot read claimed launch ticket: %1. Error: %2.")
|
||||
.arg(consumingPath, file.errorString());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const QByteArray raw = file.readAll(); file.close();
|
||||
QFile::remove(consumingPath); // Consume once; it must not be replayed regardless of success or failure.
|
||||
QJsonParseError parseError;
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(raw, &parseError);
|
||||
if (parseError.error != QJsonParseError::NoError || !doc.isObject()) {
|
||||
if (errorMessage) *errorMessage = "invalid ticket JSON"; return false;
|
||||
}
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(raw, &parseError);
|
||||
if (parseError.error != QJsonParseError::NoError || !doc.isObject()) {
|
||||
if (errorMessage) {
|
||||
*errorMessage = QCoreApplication::translate(
|
||||
"TicketHelper",
|
||||
"Launch ticket is not valid JSON. JSON error: %1.")
|
||||
.arg(parseError.errorString());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const QJsonObject wrapper = doc.object();
|
||||
const QJsonObject payload = wrapper.value("payload").toObject();
|
||||
const QByteArray actual = wrapper.value("signature").toString().toLatin1();
|
||||
@@ -84,27 +124,57 @@ bool TicketHelper::consumeAndVerify(const QString& ticketPath, const QString& ex
|
||||
const bool timeOk = issued.isValid() && expires.isValid() && issued <= now.addSecs(5)
|
||||
&& expires >= now && issued.secsTo(expires) <= 65;
|
||||
if (actual.isEmpty() || actual != expected) {
|
||||
if (errorMessage) *errorMessage = "ticket signature invalid; check launch_token";
|
||||
if (errorMessage) {
|
||||
*errorMessage = QCoreApplication::translate(
|
||||
"TicketHelper",
|
||||
"Launch ticket signature is invalid. The launch_token used by Launcher and the main application is inconsistent, or the ticket content was changed.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (payload.value("app_id").toString() != expectedAppId) {
|
||||
if (errorMessage) *errorMessage = "ticket app_id mismatch";
|
||||
if (errorMessage) {
|
||||
*errorMessage = QCoreApplication::translate(
|
||||
"TicketHelper",
|
||||
"Launch ticket app_id does not match. Ticket app_id: %1. Expected app_id: %2.")
|
||||
.arg(payload.value("app_id").toString(), expectedAppId);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (payload.value("device_id").toString() != expectedDeviceId) {
|
||||
if (errorMessage) *errorMessage = "ticket device_id mismatch";
|
||||
if (errorMessage) {
|
||||
*errorMessage = QCoreApplication::translate(
|
||||
"TicketHelper",
|
||||
"Launch ticket device_id does not match. Ticket device_id: %1. Expected device_id: %2. Reauthorize the device from Launcher if the configuration was regenerated.")
|
||||
.arg(payload.value("device_id").toString(), expectedDeviceId);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (payload.value("version").toString() != expectedVersion) {
|
||||
if (errorMessage) *errorMessage = "ticket version mismatch";
|
||||
if (errorMessage) {
|
||||
*errorMessage = QCoreApplication::translate(
|
||||
"TicketHelper",
|
||||
"Launch ticket version does not match. Ticket version: %1. Expected version: %2.")
|
||||
.arg(payload.value("version").toString(), expectedVersion);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (!timeOk) {
|
||||
if (errorMessage) *errorMessage = "ticket time invalid or expired";
|
||||
if (errorMessage) {
|
||||
*errorMessage = QCoreApplication::translate(
|
||||
"TicketHelper",
|
||||
"Launch ticket time is invalid or expired. Issued at: %1. Expires at: %2. Current UTC time: %3.")
|
||||
.arg(payload.value("issued_at").toString(),
|
||||
payload.value("expires_at").toString(),
|
||||
now.toString(Qt::ISODate));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (payload.value("nonce").toString().isEmpty()) {
|
||||
if (errorMessage) *errorMessage = "ticket nonce missing";
|
||||
if (errorMessage) {
|
||||
*errorMessage = QCoreApplication::translate(
|
||||
"TicketHelper",
|
||||
"Launch ticket nonce is missing. The ticket is incomplete.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
Reference in New Issue
Block a user