chore: initialize client repository
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
project(Common LANGUAGES C CXX)
|
||||
find_package(Qt5 REQUIRED COMPONENTS Core Network Widgets)
|
||||
|
||||
set(SRC
|
||||
HttpHelper.h
|
||||
HttpHelper.cpp
|
||||
FileHelper.h
|
||||
FileHelper.cpp
|
||||
ConfigHelper.h
|
||||
ConfigHelper.cpp
|
||||
PolicyHelper.h
|
||||
PolicyHelper.cpp
|
||||
LocalStateHelper.h
|
||||
LocalStateHelper.cpp
|
||||
TicketHelper.h
|
||||
TicketHelper.cpp
|
||||
IntegrityHelper.h
|
||||
IntegrityHelper.cpp
|
||||
DeviceIdentityHelper.h
|
||||
DeviceIdentityHelper.cpp
|
||||
)
|
||||
add_library(Common STATIC ${SRC})
|
||||
|
||||
# Common编译自身需要OpenSSL头文件
|
||||
target_include_directories(Common
|
||||
PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}
|
||||
PRIVATE ${OPENSSL_INC}
|
||||
)
|
||||
|
||||
# 链接库、库目录通过INTERFACE传递给所有依赖Common的exe
|
||||
target_link_directories(Common INTERFACE
|
||||
$<$<CONFIG:Debug>:${OPENSSL_LIB_DEBUG}>
|
||||
$<$<CONFIG:Release>:${OPENSSL_LIB_RELEASE}>
|
||||
)
|
||||
target_link_libraries(Common
|
||||
PRIVATE Qt5::Core Qt5::Network Qt5::Widgets
|
||||
INTERFACE libssl.lib libcrypto.lib
|
||||
)
|
||||
@@ -0,0 +1,181 @@
|
||||
#include "ConfigHelper.h"
|
||||
#include <QApplication>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QSaveFile>
|
||||
#include <QSettings>
|
||||
#include <QDebug>
|
||||
|
||||
ConfigHelper& ConfigHelper::instance()
|
||||
{
|
||||
static ConfigHelper obj;
|
||||
return obj;
|
||||
}
|
||||
|
||||
ConfigHelper::ConfigHelper()
|
||||
{
|
||||
m_configPath = QApplication::applicationDirPath() + "/config/app_config.json";
|
||||
migrateLegacyIniIfNeeded();
|
||||
qDebug() << "Loading app config path:" << m_configPath;
|
||||
qDebug() << "File exists?" << QFile::exists(m_configPath);
|
||||
}
|
||||
|
||||
QString ConfigHelper::configPath() const
|
||||
{
|
||||
return m_configPath;
|
||||
}
|
||||
|
||||
QString ConfigHelper::installRoot() const
|
||||
{
|
||||
QString relativeRoot = getValue("Runtime", "install_root").trimmed();
|
||||
if (relativeRoot.isEmpty())
|
||||
relativeRoot = ".";
|
||||
return QDir::cleanPath(QDir(runtimeRoot()).filePath(relativeRoot));
|
||||
}
|
||||
|
||||
QString ConfigHelper::runtimeRoot() const
|
||||
{
|
||||
return QDir::cleanPath(QApplication::applicationDirPath());
|
||||
}
|
||||
|
||||
QString ConfigHelper::updateRoot() const
|
||||
{
|
||||
return QDir(runtimeRoot()).filePath("update");
|
||||
}
|
||||
|
||||
QString ConfigHelper::runtimeRelativePath() const
|
||||
{
|
||||
QString relative = QDir::fromNativeSeparators(QDir(installRoot()).relativeFilePath(runtimeRoot()));
|
||||
relative = QDir::cleanPath(relative);
|
||||
if (relative == ".")
|
||||
return QString();
|
||||
while (relative.startsWith("./"))
|
||||
relative = relative.mid(2);
|
||||
return relative.trimmed();
|
||||
}
|
||||
|
||||
QString ConfigHelper::lastError() const
|
||||
{
|
||||
return m_error;
|
||||
}
|
||||
|
||||
QString ConfigHelper::getValue(const QString& section, const QString& key) const
|
||||
{
|
||||
Q_UNUSED(section);
|
||||
QFile file(m_configPath);
|
||||
if (!file.open(QIODevice::ReadOnly))
|
||||
return QString();
|
||||
|
||||
QJsonParseError error;
|
||||
const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &error);
|
||||
if (error.error != QJsonParseError::NoError || !document.isObject())
|
||||
return QString();
|
||||
|
||||
return document.object().value(key).toVariant().toString();
|
||||
}
|
||||
|
||||
bool ConfigHelper::setValue(const QString& section, const QString& key, const QString& value)
|
||||
{
|
||||
Q_UNUSED(section);
|
||||
m_error.clear();
|
||||
|
||||
QFile input(m_configPath);
|
||||
QJsonObject config;
|
||||
if (input.exists())
|
||||
{
|
||||
if (!input.open(QIODevice::ReadOnly))
|
||||
{
|
||||
m_error = QString("Cannot open config for reading: %1").arg(input.errorString());
|
||||
return false;
|
||||
}
|
||||
QJsonParseError parseError;
|
||||
const QByteArray raw = input.readAll();
|
||||
input.close();
|
||||
const QJsonDocument document = QJsonDocument::fromJson(raw, &parseError);
|
||||
if (parseError.error != QJsonParseError::NoError || !document.isObject())
|
||||
{
|
||||
m_error = QString("Invalid config JSON: %1").arg(parseError.errorString());
|
||||
return false;
|
||||
}
|
||||
config = document.object();
|
||||
}
|
||||
|
||||
config.insert(key, value);
|
||||
QDir dir(QFileInfo(m_configPath).path());
|
||||
if (!dir.exists() && !dir.mkpath("."))
|
||||
{
|
||||
m_error = QString("Cannot create config directory: %1").arg(dir.path());
|
||||
return false;
|
||||
}
|
||||
|
||||
QSaveFile output(m_configPath);
|
||||
if (!output.open(QIODevice::WriteOnly))
|
||||
{
|
||||
m_error = QString("Cannot open config for writing: %1").arg(output.errorString());
|
||||
return false;
|
||||
}
|
||||
const QByteArray payload = QJsonDocument(config).toJson(QJsonDocument::Indented);
|
||||
if (output.write(payload) != payload.size())
|
||||
{
|
||||
m_error = QString("Cannot write full config file: %1").arg(output.errorString());
|
||||
output.cancelWriting();
|
||||
return false;
|
||||
}
|
||||
if (!output.commit())
|
||||
{
|
||||
m_error = QString("Cannot commit config file: %1").arg(output.errorString());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ConfigHelper::migrateLegacyIniIfNeeded()
|
||||
{
|
||||
if (QFile::exists(m_configPath))
|
||||
return true;
|
||||
|
||||
const QString legacyPath = QApplication::applicationDirPath() + "/client.ini";
|
||||
if (!QFile::exists(legacyPath))
|
||||
return false;
|
||||
|
||||
QSettings ini(legacyPath, QSettings::IniFormat);
|
||||
QJsonObject config;
|
||||
const auto copyText = [&](const QString& section, const QString& key, const QString& fallback = QString()) {
|
||||
const QString value = ini.value(section + "/" + key, fallback).toString();
|
||||
config.insert(key, value);
|
||||
};
|
||||
|
||||
copyText("App", "app_id");
|
||||
copyText("App", "app_name", "Marsco Demo App");
|
||||
copyText("App", "channel", "stable");
|
||||
copyText("App", "current_version", "1.0.0");
|
||||
copyText("App", "client_protocol", "3");
|
||||
copyText("App", "launch_token");
|
||||
copyText("License", "license_key");
|
||||
copyText("Server", "api_base_url");
|
||||
copyText("Server", "client_token");
|
||||
copyText("Update", "request_timeout_ms", "5000");
|
||||
copyText("Update", "temp_folder", "update_temp");
|
||||
copyText("Update", "device_id");
|
||||
copyText("Runtime", "install_root", ".");
|
||||
copyText("Runtime", "main_executable", "MainApp.exe");
|
||||
copyText("Runtime", "launcher_executable", "Launcher.exe");
|
||||
copyText("Runtime", "updater_executable", "Updater.exe");
|
||||
copyText("Runtime", "bootstrap_executable", "Bootstrap.exe");
|
||||
copyText("Runtime", "health_check_timeout_ms", "15000");
|
||||
config.insert("platform", "windows");
|
||||
config.insert("arch", "x64");
|
||||
|
||||
QDir().mkpath(QFileInfo(m_configPath).path());
|
||||
QSaveFile output(m_configPath);
|
||||
if (!output.open(QIODevice::WriteOnly))
|
||||
return false;
|
||||
output.write(QJsonDocument(config).toJson(QJsonDocument::Indented));
|
||||
const bool saved = output.commit();
|
||||
if (saved)
|
||||
qDebug() << "Migrated legacy client.ini to" << m_configPath;
|
||||
return saved;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
#pragma once
|
||||
#include <QString>
|
||||
|
||||
class ConfigHelper
|
||||
{
|
||||
public:
|
||||
static ConfigHelper& instance();
|
||||
QString getValue(const QString& section, const QString& key) const;
|
||||
bool setValue(const QString& section, const QString& key, const QString& value);
|
||||
QString configPath() const;
|
||||
QString installRoot() const;
|
||||
QString runtimeRoot() const;
|
||||
QString updateRoot() const;
|
||||
QString runtimeRelativePath() const;
|
||||
QString lastError() const;
|
||||
|
||||
private:
|
||||
ConfigHelper();
|
||||
bool migrateLegacyIniIfNeeded();
|
||||
QString m_configPath;
|
||||
QString m_error;
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
#include "DeviceIdentityHelper.h"
|
||||
#include "ConfigHelper.h"
|
||||
#include <QCryptographicHash>
|
||||
#include <QDateTime>
|
||||
#include <QDir>
|
||||
#include <QEventLoop>
|
||||
#include <QFile>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QNetworkReply>
|
||||
#include <QNetworkRequest>
|
||||
#include <QSaveFile>
|
||||
#include <QSysInfo>
|
||||
#include <QUuid>
|
||||
#include <QTimer>
|
||||
#ifdef HAVE_OPENSSL
|
||||
#include <openssl/evp.h>
|
||||
#include <openssl/pem.h>
|
||||
#endif
|
||||
DeviceIdentityHelper::DeviceIdentityHelper(const QString& dir):m_installDir(dir){}
|
||||
QString DeviceIdentityHelper::deviceId() const{return m_deviceId;}
|
||||
QString DeviceIdentityHelper::errorString() const{return m_error;}
|
||||
bool DeviceIdentityHelper::verifySignature(const QByteArray& payload,const QString& sig64){
|
||||
#ifndef HAVE_OPENSSL
|
||||
Q_UNUSED(payload);Q_UNUSED(sig64);m_error="OpenSSL unavailable";return false;
|
||||
#else
|
||||
QFile f(QDir(m_installDir).filePath("config/manifest_public_key.pem")); if(!f.open(QIODevice::ReadOnly)){m_error="device public key missing";return false;}
|
||||
QByteArray kd=f.readAll();BIO* b=BIO_new_mem_buf(kd.constData(),kd.size());EVP_PKEY* k=b?PEM_read_bio_PUBKEY(b,nullptr,nullptr,nullptr):nullptr;if(b)BIO_free(b);if(!k){m_error="device public key invalid";return false;}
|
||||
EVP_MD_CTX* c=EVP_MD_CTX_new();QByteArray sig=QByteArray::fromBase64(sig64.toUtf8());bool ok=c&&EVP_DigestVerifyInit(c,nullptr,EVP_sha256(),nullptr,k)==1&&EVP_DigestVerifyUpdate(c,payload.constData(),payload.size())==1&&EVP_DigestVerifyFinal(c,reinterpret_cast<const unsigned char*>(sig.constData()),sig.size())==1;if(c)EVP_MD_CTX_free(c);EVP_PKEY_free(k);if(!ok)m_error="device credential RSA signature invalid";return ok;
|
||||
#endif
|
||||
}
|
||||
bool DeviceIdentityHelper::loadAndVerify(const QString& appId,const QString& channel){
|
||||
QFile f(QDir(m_installDir).filePath("config/client_identity.dat"));if(!f.open(QIODevice::ReadOnly))return false;QJsonParseError e;auto d=QJsonDocument::fromJson(f.readAll(),&e);if(e.error!=QJsonParseError::NoError||!d.isObject()){m_error="device credential JSON invalid";return false;}auto w=d.object();QByteArray text=w.value("identity_text").toString().toUtf8();if(text.isEmpty()||!verifySignature(text,w.value("signature").toString()))return false;auto identity=QJsonDocument::fromJson(text).object();QDateTime expiry=QDateTime::fromString(identity.value("valid_until").toString(),Qt::ISODate);if(identity.value("app_id").toString()!=appId||identity.value("channel").toString()!=channel||identity.value("license_id").toString().isEmpty()||identity.value("installation_id").toString().isEmpty()||identity.value("device_id").toString().isEmpty()){m_error="device/license credential identity mismatch";return false;}if(!expiry.isValid()||expiry<=QDateTime::currentDateTimeUtc()){m_error="license expired";return false;}m_deviceId=identity.value("device_id").toString();return true;
|
||||
}
|
||||
bool DeviceIdentityHelper::verifyLocal(const QString& appId,const QString& channel){m_error.clear();return loadAndVerify(appId,channel);}
|
||||
bool DeviceIdentityHelper::ensureIssued(const QString& base,const QString& token,const QString& appId,const QString& channel,const QString& licenseKey){
|
||||
m_error.clear();if(loadAndVerify(appId,channel)){ConfigHelper::instance().setValue("Update","device_id",m_deviceId);return true;}
|
||||
const QString trimmedBase=base.trimmed();
|
||||
if(appId.trimmed().isEmpty()){m_error="app_id is empty in config/app_config.json";return false;}
|
||||
if(channel.trimmed().isEmpty()){m_error="channel is empty in config/app_config.json";return false;}
|
||||
if(trimmedBase.isEmpty()||trimmedBase.contains("YOUR_SERVER_IP",Qt::CaseInsensitive)){m_error="api_base_url is not configured. Set it to the update server address, for example http://192.168.229.128:8000";return false;}
|
||||
if(token.trimmed().isEmpty()){m_error="client_token is empty in config/app_config.json";return false;}
|
||||
if(licenseKey.trimmed().isEmpty()){m_error="license_key is empty. Create a License in the admin page and paste the generated key into config/app_config.json";return false;}
|
||||
ConfigHelper& config=ConfigHelper::instance();
|
||||
QString installation=config.getValue("Device","installation_id");if(installation.isEmpty()){installation=QUuid::createUuid().toString(QUuid::WithoutBraces);if(!config.setValue("Device","installation_id",installation)){m_error=QString("cannot save installation id to %1: %2").arg(config.configPath(),config.lastError());return false;}}
|
||||
QByteArray machine=QSysInfo::machineUniqueId()+installation.toUtf8();QString mh=QString::fromLatin1(QCryptographicHash::hash(machine,QCryptographicHash::Sha256).toHex());QJsonObject body{{"app_id",appId},{"channel",channel},{"license_key",licenseKey},{"installation_id",installation},{"machine_hash",mh}};
|
||||
QNetworkAccessManager manager;QNetworkRequest req{QUrl(trimmedBase+"/api/v1/device/issue")};req.setHeader(QNetworkRequest::ContentTypeHeader,"application/json");req.setRawHeader("X-Client-Token",token.toUtf8());QNetworkReply* reply=manager.post(req,QJsonDocument(body).toJson(QJsonDocument::Compact));QEventLoop loop;bool timeoutOk=false;int timeoutMs=ConfigHelper::instance().getValue("Update","request_timeout_ms").toInt(&timeoutOk);if(!timeoutOk||timeoutMs<1000)timeoutMs=5000;QTimer timer;timer.setSingleShot(true);QObject::connect(&timer,&QTimer::timeout,[&](){if(reply&&reply->isRunning())reply->abort();});QObject::connect(reply,&QNetworkReply::finished,&loop,&QEventLoop::quit);timer.start(timeoutMs);loop.exec();timer.stop();int status=reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();QByteArray raw=reply->readAll();reply->deleteLater();if(status!=200){m_error=QString("device issue failed (HTTP %1): %2").arg(status).arg(QString::fromUtf8(raw));return false;}auto response=QJsonDocument::fromJson(raw).object();QJsonObject wrapper{{"identity_text",response.value("identity_text")},{"signature",response.value("signature")}};QString path=QDir(m_installDir).filePath("config/client_identity.dat");QSaveFile out(path);QByteArray bytes=QJsonDocument(wrapper).toJson(QJsonDocument::Compact);if(!out.open(QIODevice::WriteOnly)||out.write(bytes)!=bytes.size()||!out.commit()){m_error=QString("cannot save device credential to %1: %2").arg(path,out.errorString());return false;}if(!loadAndVerify(appId,channel))return false;if(!config.setValue("Update","device_id",m_deviceId)){m_error=QString("cannot save server device id to %1: %2").arg(config.configPath(),config.lastError());return false;}return true;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
#pragma once
|
||||
#include <QString>
|
||||
class DeviceIdentityHelper {
|
||||
public:
|
||||
explicit DeviceIdentityHelper(const QString& installDir);
|
||||
bool ensureIssued(const QString& apiBaseUrl, const QString& clientToken, const QString& appId,
|
||||
const QString& channel, const QString& licenseKey);
|
||||
bool verifyLocal(const QString& appId, const QString& channel);
|
||||
QString deviceId() const;
|
||||
QString errorString() const;
|
||||
private:
|
||||
bool loadAndVerify(const QString& expectedAppId, const QString& expectedChannel);
|
||||
bool verifySignature(const QByteArray& payload, const QString& signatureBase64);
|
||||
QString m_installDir, m_deviceId, m_error;
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
#include "FileHelper.h"
|
||||
#include <QDebug>
|
||||
|
||||
bool FileHelper::createDir(const QString &path)
|
||||
{
|
||||
QDir dir(path);
|
||||
if (!dir.exists())
|
||||
return dir.mkpath(".");
|
||||
return true;
|
||||
}
|
||||
|
||||
bool FileHelper::copyFileOverwrite(const QString &src, const QString &dst)
|
||||
{
|
||||
if (QFile::exists(dst))
|
||||
{
|
||||
if (!QFile::remove(dst))
|
||||
{
|
||||
qDebug() << "无法删除旧文件:" << dst;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return QFile::copy(src, dst);
|
||||
}
|
||||
|
||||
bool FileHelper::isProcessRunning(const QString &exeName)
|
||||
{
|
||||
QProcess process;
|
||||
process.start("tasklist");
|
||||
process.waitForFinished();
|
||||
QString output = process.readAllStandardOutput();
|
||||
return output.contains(exeName, Qt::CaseInsensitive);
|
||||
}
|
||||
|
||||
bool FileHelper::killProcess(const QString &exeName)
|
||||
{
|
||||
if (!isProcessRunning(exeName))
|
||||
return true;
|
||||
QProcess process;
|
||||
process.start("taskkill /f /im " + exeName);
|
||||
process.waitForFinished(1000);
|
||||
return !isProcessRunning(exeName);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
#pragma once
|
||||
#include <QString>
|
||||
#include <QFile>
|
||||
#include <QDir>
|
||||
#include <QProcess>
|
||||
|
||||
class FileHelper
|
||||
{
|
||||
public:
|
||||
static bool createDir(const QString& path);
|
||||
static bool copyFileOverwrite(const QString& src, const QString& dst);
|
||||
static bool isProcessRunning(const QString& exeName);
|
||||
static bool killProcess(const QString& exeName);
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
#include "HttpHelper.h"
|
||||
#include <QNetworkProxy>
|
||||
#include "ConfigHelper.h"
|
||||
#include <QFile>
|
||||
#include <QDir>
|
||||
#include <QApplication>
|
||||
#include <QTimer>
|
||||
|
||||
void HttpHelper::postRequest(const QString& url, const QJsonObject& jsonBody,
|
||||
std::function<void(int code, const QJsonObject& resp)> callback)
|
||||
{
|
||||
QNetworkAccessManager* manager = new QNetworkAccessManager();
|
||||
manager->setProxy(QNetworkProxy::NoProxy);
|
||||
|
||||
QNetworkRequest req(url);
|
||||
req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
|
||||
// Add auth token header
|
||||
QString token = ConfigHelper::instance().getValue("Server", "client_token");
|
||||
req.setRawHeader("X-Client-Token", token.toUtf8());
|
||||
QFile identity(QDir(QApplication::applicationDirPath()).filePath("config/client_identity.dat"));
|
||||
if (identity.open(QIODevice::ReadOnly))
|
||||
req.setRawHeader("X-Device-Credential", identity.readAll().toBase64());
|
||||
|
||||
QByteArray data = QJsonDocument(jsonBody).toJson(QJsonDocument::Compact);
|
||||
qDebug() << "=== POST Request ===";
|
||||
qDebug() << "Url:" << url;
|
||||
qDebug() << "Body:" << data;
|
||||
|
||||
QNetworkReply* reply = manager->post(req, data);
|
||||
QEventLoop loop;
|
||||
bool timeoutOk = false;
|
||||
int timeoutMs = ConfigHelper::instance().getValue("Update", "request_timeout_ms").toInt(&timeoutOk);
|
||||
if (!timeoutOk || timeoutMs < 1000) timeoutMs = 5000;
|
||||
QTimer timer;
|
||||
timer.setSingleShot(true);
|
||||
QObject::connect(&timer, &QTimer::timeout, [&]() {
|
||||
if (reply && reply->isRunning()) {
|
||||
qDebug() << "Request timeout, abort:" << url;
|
||||
reply->abort();
|
||||
}
|
||||
});
|
||||
|
||||
QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
|
||||
timer.start(timeoutMs);
|
||||
loop.exec();
|
||||
timer.stop();
|
||||
|
||||
int retCode = 0;
|
||||
QJsonObject retObj;
|
||||
|
||||
retCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
||||
const QByteArray respData = reply->readAll();
|
||||
if (!respData.isEmpty()) {
|
||||
qDebug() << "Server raw response:" << respData;
|
||||
retObj = QJsonDocument::fromJson(respData).object();
|
||||
}
|
||||
if (reply->error() != QNetworkReply::NoError)
|
||||
{
|
||||
qDebug() << "Network error code:" << reply->error();
|
||||
qDebug() << "HTTP status:" << retCode << "detail:" << reply->errorString();
|
||||
}
|
||||
|
||||
callback(retCode, retObj);
|
||||
|
||||
reply->deleteLater();
|
||||
manager->deleteLater();
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
#pragma once
|
||||
#include <QObject>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QNetworkRequest>
|
||||
#include <QNetworkReply>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonDocument>
|
||||
#include <QEventLoop>
|
||||
#include <QDebug>
|
||||
|
||||
class HttpHelper
|
||||
{
|
||||
public:
|
||||
// 每次调用独立创建manager,不做成成员变量
|
||||
static void postRequest(const QString& url, const QJsonObject& jsonBody,
|
||||
std::function<void(int code, const QJsonObject& resp)> callback);
|
||||
};
|
||||
@@ -0,0 +1,153 @@
|
||||
#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; }
|
||||
|
||||
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{
|
||||
"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.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 = "OpenSSL unavailable"; 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; }
|
||||
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();
|
||||
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<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;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
#pragma once
|
||||
#include <QString>
|
||||
|
||||
class IntegrityHelper
|
||||
{
|
||||
public:
|
||||
explicit IntegrityHelper(const QString& installDir);
|
||||
bool verifyInstalledVersion(const QString& appId, const QString& channel,
|
||||
const QString& version);
|
||||
QString errorString() const;
|
||||
|
||||
private:
|
||||
bool verifySignature(const QByteArray& payload, const QString& signatureBase64);
|
||||
bool safeRelativePath(const QString& path) const;
|
||||
bool runtimeProtectedPath(const QString& path) const;
|
||||
QString sha256(const QString& filePath) const;
|
||||
|
||||
QString m_installDir;
|
||||
QString m_error;
|
||||
};
|
||||
@@ -0,0 +1,147 @@
|
||||
#include "LocalStateHelper.h"
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonDocument>
|
||||
#include <QDir>
|
||||
|
||||
LocalStateHelper::LocalStateHelper(const QString& baseDir)
|
||||
: m_baseDir(baseDir)
|
||||
, m_filePath(baseDir + "/config/local_state.json")
|
||||
{
|
||||
}
|
||||
|
||||
bool LocalStateHelper::loadState(const QString& relativePath)
|
||||
{
|
||||
m_filePath = m_baseDir + "/" + relativePath;
|
||||
QFile file(m_filePath);
|
||||
if (!file.exists())
|
||||
{
|
||||
QDir dir(QFileInfo(m_filePath).path());
|
||||
if (!dir.exists() && !dir.mkpath("."))
|
||||
{
|
||||
m_error = QString("Cannot create state directory: %1").arg(dir.path());
|
||||
return false;
|
||||
}
|
||||
|
||||
m_state = QJsonObject{
|
||||
{"max_policy_seq", 0},
|
||||
{"last_success_run_at", QString()},
|
||||
{"last_online_verified_at", QString()},
|
||||
{"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 (!file.open(QIODevice::ReadOnly))
|
||||
{
|
||||
m_error = QString("Cannot open state file: %1").arg(m_filePath);
|
||||
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;
|
||||
}
|
||||
|
||||
m_state = doc.object();
|
||||
m_loaded = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool LocalStateHelper::saveState() const
|
||||
{
|
||||
if (!m_loaded)
|
||||
return false;
|
||||
|
||||
QFile file(m_filePath);
|
||||
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
QJsonDocument doc(m_state);
|
||||
file.write(doc.toJson(QJsonDocument::Indented));
|
||||
file.close();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool LocalStateHelper::isLoaded() const
|
||||
{
|
||||
return m_loaded;
|
||||
}
|
||||
|
||||
QString LocalStateHelper::errorString() const
|
||||
{
|
||||
return m_error;
|
||||
}
|
||||
|
||||
qint64 LocalStateHelper::maxPolicySeq() const
|
||||
{
|
||||
return m_state.value("max_policy_seq").toVariant().toLongLong();
|
||||
}
|
||||
|
||||
QDateTime LocalStateHelper::lastSuccessRunAt() const
|
||||
{
|
||||
return QDateTime::fromString(m_state.value("last_success_run_at").toString(), Qt::ISODate);
|
||||
}
|
||||
|
||||
QDateTime LocalStateHelper::lastOnlineVerifiedAt() const
|
||||
{
|
||||
return QDateTime::fromString(m_state.value("last_online_verified_at").toString(), Qt::ISODate);
|
||||
}
|
||||
|
||||
QString LocalStateHelper::lastSuccessVersion() const
|
||||
{
|
||||
return m_state.value("last_success_version").toString();
|
||||
}
|
||||
|
||||
bool LocalStateHelper::isPolicySeqRolledBack(qint64 policySeq) const
|
||||
{
|
||||
if (!m_loaded)
|
||||
return false;
|
||||
return policySeq < maxPolicySeq();
|
||||
}
|
||||
|
||||
bool LocalStateHelper::isSystemTimeRewound() const
|
||||
{
|
||||
if (!m_loaded)
|
||||
return false;
|
||||
const QDateTime now = QDateTime::currentDateTimeUtc();
|
||||
const QDateTime lastRun = lastSuccessRunAt();
|
||||
const QDateTime lastOnline = lastOnlineVerifiedAt();
|
||||
return (lastRun.isValid() && now < lastRun)
|
||||
|| (lastOnline.isValid() && now < lastOnline);
|
||||
}
|
||||
|
||||
void LocalStateHelper::updateAfterSuccessfulRun(const QString& currentVersion, qint64 policySeq)
|
||||
{
|
||||
if (!m_loaded)
|
||||
m_loaded = true;
|
||||
|
||||
m_state["last_success_run_at"] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate);
|
||||
m_state["last_success_version"] = currentVersion;
|
||||
if (policySeq > maxPolicySeq())
|
||||
m_state["max_policy_seq"] = policySeq;
|
||||
}
|
||||
|
||||
void LocalStateHelper::updateOnlineVerified(qint64 policySeq)
|
||||
{
|
||||
if (!m_loaded)
|
||||
m_loaded = true;
|
||||
|
||||
m_state["last_online_verified_at"] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate);
|
||||
if (policySeq > maxPolicySeq())
|
||||
m_state["max_policy_seq"] = policySeq;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
#pragma once
|
||||
#include <QString>
|
||||
#include <QJsonObject>
|
||||
#include <QDateTime>
|
||||
|
||||
class LocalStateHelper
|
||||
{
|
||||
public:
|
||||
explicit LocalStateHelper(const QString& baseDir);
|
||||
|
||||
bool loadState(const QString& relativePath = "config/local_state.json");
|
||||
bool saveState() const;
|
||||
bool isLoaded() const;
|
||||
QString errorString() const;
|
||||
|
||||
qint64 maxPolicySeq() const;
|
||||
QDateTime lastSuccessRunAt() const;
|
||||
QDateTime lastOnlineVerifiedAt() const;
|
||||
QString lastSuccessVersion() const;
|
||||
|
||||
bool isPolicySeqRolledBack(qint64 policySeq) const;
|
||||
bool isSystemTimeRewound() const;
|
||||
|
||||
void updateAfterSuccessfulRun(const QString& currentVersion, qint64 policySeq);
|
||||
void updateOnlineVerified(qint64 policySeq);
|
||||
|
||||
private:
|
||||
QString m_baseDir;
|
||||
QString m_filePath;
|
||||
QString m_error;
|
||||
QJsonObject m_state;
|
||||
bool m_loaded = false;
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
#include "PolicyHelper.h"
|
||||
#include <QApplication>
|
||||
#include <QDateTime>
|
||||
#include <QFile>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QSaveFile>
|
||||
#include <algorithm>
|
||||
#ifdef HAVE_OPENSSL
|
||||
#include <openssl/evp.h>
|
||||
#include <openssl/pem.h>
|
||||
#endif
|
||||
|
||||
PolicyHelper::PolicyHelper(const QString& baseDir) : m_baseDir(baseDir) {}
|
||||
|
||||
bool PolicyHelper::loadPolicy(const QString& relativePath)
|
||||
{
|
||||
QFile file(m_baseDir + "/" + relativePath);
|
||||
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());
|
||||
}
|
||||
|
||||
bool PolicyHelper::loadPolicyObject(const QJsonObject& policy, const QString& signedText)
|
||||
{
|
||||
m_policy = policy; m_signedText = signedText; m_loaded = true; m_error.clear();
|
||||
return isValid();
|
||||
}
|
||||
|
||||
bool PolicyHelper::savePolicy(const QString& relativePath) const
|
||||
{
|
||||
QSaveFile file(m_baseDir + "/" + relativePath);
|
||||
if (!file.open(QIODevice::WriteOnly)) return false;
|
||||
const QByteArray bytes = QJsonDocument(m_policy).toJson(QJsonDocument::Indented);
|
||||
return file.write(bytes) == bytes.size() && file.commit();
|
||||
}
|
||||
|
||||
QByteArray PolicyHelper::canonicalPolicyBytes(const QJsonObject& policy) const
|
||||
{
|
||||
QJsonObject copy = policy; copy.remove("signature");
|
||||
auto sortObject = [&](auto&& self, const QJsonObject& obj) -> QJsonObject {
|
||||
QStringList keys = obj.keys(); std::sort(keys.begin(), keys.end());
|
||||
QJsonObject sorted;
|
||||
for (const QString& key : keys) {
|
||||
QJsonValue value = obj.value(key);
|
||||
if (value.isObject()) value = self(self, value.toObject());
|
||||
else if (value.isArray()) {
|
||||
QJsonArray array;
|
||||
for (QJsonValue item : value.toArray()) {
|
||||
if (item.isObject()) item = self(self, item.toObject());
|
||||
array.append(item);
|
||||
}
|
||||
value = array;
|
||||
}
|
||||
sorted.insert(key, value);
|
||||
}
|
||||
return sorted;
|
||||
};
|
||||
return QJsonDocument(sortObject(sortObject, copy)).toJson(QJsonDocument::Compact);
|
||||
}
|
||||
|
||||
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; }
|
||||
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; }
|
||||
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
|
||||
}
|
||||
|
||||
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;
|
||||
const QByteArray payload = m_signedText.isEmpty() ? canonicalPolicyBytes(m_policy) : m_signedText.toUtf8();
|
||||
return verifySignature(payload, m_policy.value("signature").toString());
|
||||
}
|
||||
QString PolicyHelper::errorString() const { return m_error; }
|
||||
bool PolicyHelper::isVersionAllowed(const QString& version) const {
|
||||
if (!isValid()) return false;
|
||||
for (const QJsonValue& v : m_policy.value("disabled_versions").toArray()) if (v.toString() == version) return false;
|
||||
return true;
|
||||
}
|
||||
bool PolicyHelper::allowRun() const { return isValid() && m_policy.value("allow_run").toBool(); }
|
||||
bool PolicyHelper::forceUpdate() const { return isValid() && m_policy.value("force_update").toBool(); }
|
||||
bool PolicyHelper::allowRollback() const { return isValid() && m_policy.value("allow_rollback").toBool(); }
|
||||
bool PolicyHelper::isOfflineAllowed() const { return isValid() && m_policy.value("offline_allowed").toBool(); }
|
||||
bool PolicyHelper::isExpired() const {
|
||||
if (!isValid()) return true;
|
||||
const QDateTime expiry = QDateTime::fromString(m_policy.value("valid_until").toString(), Qt::ISODate);
|
||||
return !expiry.isValid() || QDateTime::currentDateTimeUtc() > expiry;
|
||||
}
|
||||
qint64 PolicyHelper::policySeq() const { return isValid() ? m_policy.value("policy_seq").toVariant().toLongLong() : 0; }
|
||||
QString PolicyHelper::message() const { return m_policy.value("message").toString(); }
|
||||
@@ -0,0 +1,33 @@
|
||||
#pragma once
|
||||
#include <QString>
|
||||
#include <QJsonObject>
|
||||
|
||||
class PolicyHelper
|
||||
{
|
||||
public:
|
||||
explicit PolicyHelper(const QString& baseDir);
|
||||
|
||||
bool loadPolicy(const QString& relativePath = "config/version_policy.dat");
|
||||
bool loadPolicyObject(const QJsonObject& policy, const QString& signedText = QString());
|
||||
bool savePolicy(const QString& relativePath = "config/version_policy.dat") const;
|
||||
bool isValid() const;
|
||||
QString errorString() const;
|
||||
bool isVersionAllowed(const QString& currentVersion) const;
|
||||
bool allowRun() const;
|
||||
bool forceUpdate() const;
|
||||
bool allowRollback() const;
|
||||
bool isOfflineAllowed() const;
|
||||
bool isExpired() const;
|
||||
qint64 policySeq() const;
|
||||
QString message() const;
|
||||
|
||||
private:
|
||||
QByteArray canonicalPolicyBytes(const QJsonObject& obj) const;
|
||||
bool verifySignature(const QByteArray& payload, const QString& signatureBase64) const;
|
||||
|
||||
QString m_baseDir;
|
||||
QJsonObject m_policy;
|
||||
QString m_signedText;
|
||||
mutable QString m_error;
|
||||
bool m_loaded = false;
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
#include "TicketHelper.h"
|
||||
#include <QCoreApplication>
|
||||
#include <QDateTime>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QMessageAuthenticationCode>
|
||||
#include <QSaveFile>
|
||||
#include <QStandardPaths>
|
||||
#include <QUuid>
|
||||
|
||||
static QByteArray ticketMac(const QJsonObject& payload, const QString& secret)
|
||||
{
|
||||
return QMessageAuthenticationCode::hash(
|
||||
QJsonDocument(payload).toJson(QJsonDocument::Compact), secret.toUtf8(), QCryptographicHash::Sha256).toHex();
|
||||
}
|
||||
|
||||
bool TicketHelper::createTicket(const QString& appId, const QString& deviceId,
|
||||
const QString& version, const QString& secret,
|
||||
QString* ticketPath, QString* errorMessage)
|
||||
{
|
||||
if (appId.isEmpty() || version.isEmpty() || secret.isEmpty()) {
|
||||
if (errorMessage) *errorMessage = "ticket identity or secret is empty";
|
||||
return false;
|
||||
}
|
||||
const QDateTime now = QDateTime::currentDateTimeUtc();
|
||||
QJsonObject payload{
|
||||
{"app_id", appId}, {"device_id", deviceId}, {"version", version},
|
||||
{"nonce", QUuid::createUuid().toString(QUuid::WithoutBraces)},
|
||||
{"issued_at", now.toString(Qt::ISODate)},
|
||||
{"expires_at", now.addSecs(60).toString(Qt::ISODate)},
|
||||
{"signature_alg", "HMAC-SHA256"}
|
||||
};
|
||||
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; }
|
||||
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;
|
||||
}
|
||||
QFile::setPermissions(path, QFileDevice::ReadOwner | QFileDevice::WriteOwner);
|
||||
if (ticketPath) *ticketPath = path;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TicketHelper::consumeAndVerify(const QString& ticketPath, const QString& expectedAppId,
|
||||
const QString& expectedDeviceId, const QString& expectedVersion,
|
||||
const QString& secret, QString* errorMessage)
|
||||
{
|
||||
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;
|
||||
}
|
||||
const QByteArray raw = file.readAll(); file.close();
|
||||
QFile::remove(consumingPath); // 一次性消费;无论成功失败都不能重放。
|
||||
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 QJsonObject wrapper = doc.object();
|
||||
const QJsonObject payload = wrapper.value("payload").toObject();
|
||||
const QByteArray actual = wrapper.value("signature").toString().toLatin1();
|
||||
const QByteArray expected = ticketMac(payload, secret);
|
||||
const QDateTime issued = QDateTime::fromString(payload.value("issued_at").toString(), Qt::ISODate);
|
||||
const QDateTime expires = QDateTime::fromString(payload.value("expires_at").toString(), Qt::ISODate);
|
||||
const QDateTime now = QDateTime::currentDateTimeUtc();
|
||||
const bool identityOk = payload.value("app_id").toString() == expectedAppId
|
||||
&& payload.value("device_id").toString() == expectedDeviceId
|
||||
&& payload.value("version").toString() == expectedVersion;
|
||||
const bool timeOk = issued.isValid() && expires.isValid() && issued <= now.addSecs(5)
|
||||
&& expires >= now && issued.secsTo(expires) <= 65;
|
||||
if (actual.isEmpty() || actual != expected || !identityOk || !timeOk
|
||||
|| payload.value("nonce").toString().isEmpty()) {
|
||||
if (errorMessage) *errorMessage = "ticket signature, identity, time or nonce invalid";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
#pragma once
|
||||
#include <QString>
|
||||
|
||||
class TicketHelper
|
||||
{
|
||||
public:
|
||||
static bool createTicket(const QString& appId, const QString& deviceId,
|
||||
const QString& version, const QString& secret,
|
||||
QString* ticketPath, QString* errorMessage = nullptr);
|
||||
static bool consumeAndVerify(const QString& ticketPath, const QString& expectedAppId,
|
||||
const QString& expectedDeviceId, const QString& expectedVersion,
|
||||
const QString& secret, QString* errorMessage = nullptr);
|
||||
};
|
||||
Reference in New Issue
Block a user