#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(); } 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(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; } bool TicketHelper::createTicket(const QString& appId, const QString& deviceId, const QString& version, const QString& secret, QString* ticketPath, QString* errorMessage) { if (appId.isEmpty() || deviceId.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) { if (expectedAppId.isEmpty() || expectedDeviceId.isEmpty() || expectedVersion.isEmpty() || secret.isEmpty()) { if (errorMessage) *errorMessage = "expected ticket identity or secret is incomplete"; return false; } 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); // Consume once so neither successful nor failed claims can be replayed. 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; const QString nonce = payload.value("nonce").toString(); if (actual.isEmpty() || !constantTimeEqual(actual, expected) || !identityOk || !timeOk || nonce.isEmpty()) { if (errorMessage) *errorMessage = "ticket signature, identity, time or nonce invalid"; return false; } return claimTicketNonce(expectedAppId, nonce, errorMessage); }