2026-07-10 02:45:13 +00:00
#include "TicketHelper.h"
#include <QCoreApplication>
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QJsonDocument>
#include <QJsonObject>
2026-07-21 03:02:42 +00:00
#include <QMessageAuthenticationCode>
#include <QSaveFile>
#include <QStandardPaths>
#include <QStringList>
#include <QUuid>
2026-07-10 02:45:13 +00:00
2026-07-16 08:14:51 +00:00
static QByteArray ticketMac ( const QJsonObject & payload , const QString & secret )
{
2026-07-10 02:45:13 +00:00
return QMessageAuthenticationCode :: hash (
QJsonDocument ( payload ). toJson ( QJsonDocument :: Compact ), secret . toUtf8 (), QCryptographicHash :: Sha256 ). toHex ();
}
2026-07-16 08:14:51 +00:00
bool TicketHelper :: createTicket ( const QString & appId , const QString & deviceId ,
const QString & version , const QString & secret ,
QString * ticketPath , QString * errorMessage )
{
// Launcher 启动业务主程序前生成一次性 ticket。
// ticket 只保存在临时目录、有效期 60 秒,并用 launch_token 做 HMAC,防止用户绕过 Launcher 直接启动主程序。
2026-07-21 03:02:42 +00:00
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 ;
}
2026-07-10 02:45:13 +00:00
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" );
2026-07-21 03:02:42 +00:00
if ( ! QDir (). mkpath ( dirPath )) {
if ( errorMessage ) {
* errorMessage = QCoreApplication :: translate (
"TicketHelper" ,
"Cannot create launch ticket directory: %1." )
. arg ( dirPath );
}
return false ;
}
2026-07-10 02:45:13 +00:00
const QString path = QDir ( dirPath ). filePath ( "ticket_" + QUuid :: createUuid (). toString ( QUuid :: WithoutBraces ) + ".json" );
2026-07-21 03:02:42 +00:00
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 ;
}
2026-07-10 02:45:13 +00:00
QFile :: setPermissions ( path , QFileDevice :: ReadOwner | QFileDevice :: WriteOwner );
if ( ticketPath ) * ticketPath = path ;
return true ;
}
2026-07-16 08:14:51 +00:00
bool TicketHelper :: consumeAndVerify ( const QString & ticketPath , const QString & expectedAppId ,
const QString & expectedDeviceId , const QString & expectedVersion ,
const QString & secret , QString * errorMessage )
{
// 主程序启动后第一时间把 ticket 改名成 .consuming,再读取并删除。
// 这样同一张 ticket 即使校验失败也不能被重复使用,避免重放启动。
const QString consumingPath = ticketPath + ".consuming."
2026-07-21 03:02:42 +00:00
+ 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 ;
}
2026-07-10 02:45:13 +00:00
const QByteArray raw = file . readAll (); file . close ();
2026-07-14 01:37:06 +00:00
QFile :: remove ( consumingPath ); // Consume once; it must not be replayed regardless of success or failure.
2026-07-10 02:45:13 +00:00
QJsonParseError parseError ;
2026-07-21 03:02:42 +00:00
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 ;
}
2026-07-10 02:45:13 +00:00
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 ();
2026-07-14 08:39:41 +00:00
const bool timeOk = issued . isValid () && expires . isValid () && issued <= now . addSecs ( 5 )
&& expires >= now && issued . secsTo ( expires ) <= 65 ;
if ( actual . isEmpty () || actual != expected ) {
2026-07-21 03:02:42 +00:00
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." );
}
2026-07-14 08:39:41 +00:00
return false ;
}
if ( payload . value ( "app_id" ). toString () != expectedAppId ) {
2026-07-21 03:02:42 +00:00
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 );
}
2026-07-14 08:39:41 +00:00
return false ;
}
if ( payload . value ( "device_id" ). toString () != expectedDeviceId ) {
2026-07-21 03:02:42 +00:00
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 );
}
2026-07-14 08:39:41 +00:00
return false ;
}
if ( payload . value ( "version" ). toString () != expectedVersion ) {
2026-07-21 03:02:42 +00:00
if ( errorMessage ) {
* errorMessage = QCoreApplication :: translate (
"TicketHelper" ,
"Launch ticket version does not match. Ticket version: %1. Expected version: %2." )
. arg ( payload . value ( "version" ). toString (), expectedVersion );
}
2026-07-14 08:39:41 +00:00
return false ;
}
if ( ! timeOk ) {
2026-07-21 03:02:42 +00:00
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 ));
}
2026-07-14 08:39:41 +00:00
return false ;
}
if ( payload . value ( "nonce" ). toString (). isEmpty ()) {
2026-07-21 03:02:42 +00:00
if ( errorMessage ) {
* errorMessage = QCoreApplication :: translate (
"TicketHelper" ,
"Launch ticket nonce is missing. The ticket is incomplete." );
}
2026-07-14 08:39:41 +00:00
return false ;
}
return true ;
}