2026-07-10 02:45:13 +00:00
#include "IntegrityHelper.h"
#include "ConfigHelper.h"
#include <QCryptographicHash>
#include <QDir>
#include <QDirIterator>
#include <QFile>
#include <QFileInfo>
2026-07-21 03:02:42 +00:00
#include <QJsonArray>
#include <QCoreApplication>
#include <QJsonDocument>
2026-07-10 02:45:13 +00:00
#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 ( ":" );
}
2026-07-16 08:14:51 +00:00
bool IntegrityHelper :: runtimeProtectedPath ( const QString & path ) const
{
// 这些文件属于 SDK 运行态,不参与业务版本文件的 Manifest 校验。
// 例如 app_config.json、client_identity.dat 会随安装机器变化,不能要求它们和发布包 hash 完全一致。
2026-07-14 08:39:41 +00:00
const QString p = QDir :: fromNativeSeparators ( path ). toCaseFolded ();
QSet < QString > protectedPaths {
"bootstrap" , "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" , "bootstrap.exe" , "client.ini" , "config/app_config.json" , "config/local_state.json" ,
"config/client_identity.dat" , "config/version_policy.dat"
};
2026-07-10 02:45:13 +00:00
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 )
{
2026-07-21 03:02:42 +00:00
#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
2026-07-10 02:45:13 +00:00
QString keyPath = QDir ( m_installDir ). filePath ( "config/manifest_public_key.pem" );
2026-07-21 03:02:42 +00:00
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 ;
}
2026-07-10 02:45:13 +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 );
2026-07-21 03:02:42 +00:00
if ( ! key ) {
m_error = QCoreApplication :: translate ( "IntegrityHelper" ,
"Manifest public key is invalid. Stage: installed version verification. Public key path: %1." )
. arg ( keyPath );
return false ;
}
2026-07-10 02:45:13 +00:00
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 );
2026-07-21 03:02:42 +00:00
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
}
2026-07-10 02:45:13 +00:00
2026-07-16 08:14:51 +00:00
bool IntegrityHelper :: verifyInstalledVersion ( const QString & appId , const QString & channel ,
const QString & version )
{
m_error . clear ();
// Manifest cache 来自服务端发布版本时生成的签名清单。
// 客户端先验签 Manifest,再逐个校验文件 SHA256,防止升级文件被篡改或漏替换。
QString cachePath = QDir ( ConfigHelper :: instance (). updateRoot ()). filePath (
2026-07-10 02:45:13 +00:00
"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 );
2026-07-21 03:02:42 +00:00
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 ;
}
2026-07-10 02:45:13 +00:00
QJsonParseError wrapperError ;
2026-07-21 03:02:42 +00:00
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 ;
}
2026-07-10 02:45:13 +00:00
const QJsonObject wrapper = wrapperDoc . object ();
const QByteArray manifestText = wrapper . value ( "manifest_text" ). toString (). toUtf8 ();
const QString signature = wrapper . value ( "manifest" ). toObject (). value ( "signature" ). toString ();
2026-07-21 03:02:42 +00:00
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 ;
2026-07-10 02:45:13 +00:00
QJsonParseError manifestError ;
2026-07-21 03:02:42 +00:00
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 ;
}
2026-07-10 02:45:13 +00:00
const QJsonObject manifest = manifestDoc . object ();
if ( manifest . value ( "app_id" ). toString () != appId
2026-07-21 03:02:42 +00:00
|| 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 ;
}
2026-07-10 02:45:13 +00:00
QSet < QString > declaredExecutables ;
for ( const QJsonValue & value : manifest . value ( "files" ). toArray ()) {
2026-07-21 03:02:42 +00:00
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. \n Expected SHA-256: %4 \n Actual SHA-256: %5 \n This 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 ;
}
2026-07-10 02:45:13 +00:00
const QString suffix = QFileInfo ( path ). suffix (). toCaseFolded ();
if ( suffix == "exe" || suffix == "dll" ) declaredExecutables . insert ( path . toCaseFolded ());
}
2026-07-16 08:14:51 +00:00
QDir root ( m_installDir );
QDirIterator it ( m_installDir , QDir :: Files , QDirIterator :: Subdirectories );
// 除了清单中声明的文件,还要拒绝额外出现的 exe/dll。
// 这能降低被人偷偷塞插件或可执行文件的风险。
while ( it . hasNext ()) {
2026-07-10 02:45:13 +00:00
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 ;
2026-07-21 03:02:42 +00:00
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 ;
}
2026-07-10 02:45:13 +00:00
}
return true ;
}