#include "TicketHelper.h" #include #include #include #include #include #include #include #include #include #include #include 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) { // Launcher 启动业务主程序前生成一次性 ticket。 // ticket 只保存在临时目录、有效期 60 秒,并用 launch_token 做 HMAC,防止用户绕过 Launcher 直接启动主程序。 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}, {"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 = 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 = 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; } 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." + 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 = 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(); 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 timeOk = issued.isValid() && expires.isValid() && issued <= now.addSecs(5) && expires >= now && issued.secsTo(expires) <= 65; if (actual.isEmpty() || actual != expected) { 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 = 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 = 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 = 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 = 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 = QCoreApplication::translate( "TicketHelper", "Launch ticket nonce is missing. The ticket is incomplete."); } return false; } return true; }