2026-07-09 09:17:30 +00:00
|
|
|
#include "TicketHelper.h"
|
|
|
|
|
#include <QCoreApplication>
|
|
|
|
|
#include <QDateTime>
|
|
|
|
|
#include <QDir>
|
|
|
|
|
#include <QFile>
|
2026-07-10 00:38:23 -07:00
|
|
|
#include <QFileInfo>
|
2026-07-09 09:17:30 +00:00
|
|
|
#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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-10 00:38:23 -07:00
|
|
|
static bool constantTimeEqual(const QByteArray& left, const QByteArray& right)
|
|
|
|
|
{
|
|
|
|
|
if (left.size() != right.size()) return false;
|
|
|
|
|
unsigned char difference = 0;
|
|
|
|
|
for (qsizetype index = 0; index < left.size(); ++index)
|
|
|
|
|
difference |= static_cast<unsigned char>(left.at(index) ^ right.at(index));
|
|
|
|
|
return difference == 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static bool claimTicketNonce(const QString& appId, const QString& nonce, QString* errorMessage)
|
|
|
|
|
{
|
|
|
|
|
const QString claimsRoot = QDir(QStandardPaths::writableLocation(
|
|
|
|
|
QStandardPaths::AppLocalDataLocation)).filePath("launcher-ticket-claims");
|
|
|
|
|
if (!QDir().mkpath(claimsRoot)) {
|
|
|
|
|
if (errorMessage) *errorMessage = "cannot create ticket replay state";
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
QDir claimsDirectory(claimsRoot);
|
|
|
|
|
const QDateTime staleBefore = QDateTime::currentDateTimeUtc().addSecs(-600);
|
|
|
|
|
const QFileInfoList oldClaims = claimsDirectory.entryInfoList(
|
|
|
|
|
QStringList{"*.claimed"}, QDir::Files);
|
|
|
|
|
for (const QFileInfo& claim : oldClaims)
|
|
|
|
|
if (claim.lastModified().toUTC() < staleBefore) QFile::remove(claim.absoluteFilePath());
|
|
|
|
|
|
|
|
|
|
const QByteArray claimIdentity = appId.toUtf8() + '\0' + nonce.toUtf8();
|
|
|
|
|
const QString claimName = QString::fromLatin1(
|
|
|
|
|
QCryptographicHash::hash(claimIdentity, QCryptographicHash::Sha256).toHex()) + ".claimed";
|
|
|
|
|
QFile claimFile(claimsDirectory.filePath(claimName));
|
|
|
|
|
if (!claimFile.open(QIODevice::WriteOnly | QIODevice::NewOnly)) {
|
|
|
|
|
if (errorMessage) *errorMessage = "ticket nonce was already consumed";
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
const QByteArray marker = QDateTime::currentDateTimeUtc().toString(Qt::ISODate).toUtf8();
|
|
|
|
|
if (claimFile.write(marker) != marker.size()) {
|
|
|
|
|
const QString claimPath = claimFile.fileName();
|
|
|
|
|
claimFile.close();
|
|
|
|
|
QFile::remove(claimPath);
|
|
|
|
|
if (errorMessage) *errorMessage = "cannot persist ticket replay state";
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
claimFile.close();
|
|
|
|
|
QFile::setPermissions(claimFile.fileName(), QFileDevice::ReadOwner | QFileDevice::WriteOwner);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-09 09:17:30 +00:00
|
|
|
bool TicketHelper::createTicket(const QString& appId, const QString& deviceId,
|
|
|
|
|
const QString& version, const QString& secret,
|
|
|
|
|
QString* ticketPath, QString* errorMessage)
|
|
|
|
|
{
|
2026-07-10 00:38:23 -07:00
|
|
|
if (appId.isEmpty() || deviceId.isEmpty() || version.isEmpty() || secret.isEmpty()) {
|
2026-07-09 09:17:30 +00:00
|
|
|
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)
|
|
|
|
|
{
|
2026-07-10 00:38:23 -07:00
|
|
|
if (expectedAppId.isEmpty() || expectedDeviceId.isEmpty()
|
|
|
|
|
|| expectedVersion.isEmpty() || secret.isEmpty()) {
|
|
|
|
|
if (errorMessage) *errorMessage = "expected ticket identity or secret is incomplete";
|
|
|
|
|
return false;
|
|
|
|
|
}
|
2026-07-09 09:17:30 +00:00
|
|
|
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();
|
2026-07-10 00:38:23 -07:00
|
|
|
QFile::remove(consumingPath); // Consume once so neither successful nor failed claims can be replayed.
|
2026-07-09 09:17:30 +00:00
|
|
|
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;
|
2026-07-10 00:38:23 -07:00
|
|
|
const QString nonce = payload.value("nonce").toString();
|
|
|
|
|
if (actual.isEmpty() || !constantTimeEqual(actual, expected) || !identityOk || !timeOk
|
|
|
|
|
|| nonce.isEmpty()) {
|
2026-07-09 09:17:30 +00:00
|
|
|
if (errorMessage) *errorMessage = "ticket signature, identity, time or nonce invalid";
|
|
|
|
|
return false;
|
|
|
|
|
}
|
2026-07-10 00:38:23 -07:00
|
|
|
return claimTicketNonce(expectedAppId, nonce, errorMessage);
|
2026-07-09 09:17:30 +00:00
|
|
|
}
|