From ea262ddfb7fbc0edd5581665b816596916861038 Mon Sep 17 00:00:00 2001 From: Joshua Goins Date: Sun, 4 May 2025 16:57:29 -0400 Subject: [PATCH] Support Steam service accounts (#32) This adds support for Steam service accounts, finally. Requires an updated steamwrap, and we're missing the infrastructure to launch it at the moment too. --- README.md | 2 - autotests/CMakeLists.txt | 12 +++++ autotests/crtrandtest.cpp | 44 +++++++++++++++ autotests/encryptedargtest.cpp | 50 +++++++++++++++++ external/libphysis | 2 +- launcher/CMakeLists.txt | 4 +- launcher/include/crtrand.h | 17 ++++++ launcher/include/encryptedarg.h | 3 +- launcher/include/launchercore.h | 4 +- launcher/include/steamapi.h | 11 ++-- launcher/src/crtrand.cpp | 15 ++++++ launcher/src/encryptedarg.cpp | 83 ++++++++++++++++++++++++++++- launcher/src/launchercore.cpp | 16 +++--- launcher/src/main.cpp | 5 -- launcher/src/squareenixlogin.cpp | 27 ++++------ launcher/src/steamapi.cpp | 43 ++++++++++++--- launcher/ui/Setup/AddSquareEnix.qml | 17 +----- 17 files changed, 286 insertions(+), 69 deletions(-) create mode 100644 autotests/crtrandtest.cpp create mode 100644 autotests/encryptedargtest.cpp create mode 100644 launcher/include/crtrand.h create mode 100644 launcher/src/crtrand.cpp diff --git a/README.md b/README.md index 88782b2..77965aa 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,6 @@ plugins! * Game Patching Support: Can patch the game without the need to boot into the official launcher. * Alternative Server Support: Can use alternative servers in case the official ones ever disappear. -**Note:** Steam-linked Square Enix accounts are not currently supported. You will have to use the official launcher or XIVLauncher.Core. - ## Get It Details on where to find stable releases of Astra can be found on its [homepage](https://xiv.zone/astra/install). diff --git a/autotests/CMakeLists.txt b/autotests/CMakeLists.txt index 376173c..d496edc 100644 --- a/autotests/CMakeLists.txt +++ b/autotests/CMakeLists.txt @@ -24,3 +24,15 @@ ecm_add_test(utilitytest.cpp LINK_LIBRARIES astra_static Qt::Test NAME_PREFIX "astra-" ) + +ecm_add_test(crtrandtest.cpp + TEST_NAME crtrandtest + LINK_LIBRARIES astra_static Qt::Test + NAME_PREFIX "astra-" +) + +ecm_add_test(encryptedargtest.cpp + TEST_NAME encryptedargtest + LINK_LIBRARIES astra_static Qt::Test + NAME_PREFIX "astra-" +) diff --git a/autotests/crtrandtest.cpp b/autotests/crtrandtest.cpp new file mode 100644 index 0000000..0821714 --- /dev/null +++ b/autotests/crtrandtest.cpp @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: 2025 Joshua Goins +// SPDX-License-Identifier: GPL-3.0-or-later + +#include + +#include "crtrand.h" + +class CrtRandTest : public QObject +{ + Q_OBJECT + +private Q_SLOTS: + void randomSeed_data() + { + QTest::addColumn("seed"); + QTest::addColumn("value1"); + QTest::addColumn("value2"); + QTest::addColumn("value3"); + QTest::addColumn("value4"); + + QTest::addRow("test 1") << static_cast(5050) << static_cast(16529) << static_cast(23363) << static_cast(25000) + << static_cast(18427); + QTest::addRow("test 2") << static_cast(19147) << static_cast(29796) << static_cast(24416) << static_cast(1377) + << static_cast(24625); + } + + void randomSeed() + { + QFETCH(uint32_t, seed); + QFETCH(uint32_t, value1); + QFETCH(uint32_t, value2); + QFETCH(uint32_t, value3); + QFETCH(uint32_t, value4); + + auto crtRand = CrtRand(seed); + QCOMPARE(crtRand.next(), value1); + QCOMPARE(crtRand.next(), value2); + QCOMPARE(crtRand.next(), value3); + QCOMPARE(crtRand.next(), value4); + } +}; + +QTEST_MAIN(CrtRandTest) +#include "crtrandtest.moc" diff --git a/autotests/encryptedargtest.cpp b/autotests/encryptedargtest.cpp new file mode 100644 index 0000000..d69d7ff --- /dev/null +++ b/autotests/encryptedargtest.cpp @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: 2025 Joshua Goins +// SPDX-License-Identifier: GPL-3.0-or-later + +#include + +#include "encryptedarg.h" + +class EncryptedArgTest : public QObject +{ + Q_OBJECT + +private Q_SLOTS: + void steamTicket_data() + { + QTest::addColumn("ticketBytes"); + QTest::addColumn("time"); + QTest::addColumn("encryptedTicket"); + QTest::addColumn("encryptedLength"); + + QTest::addRow("real ticket") + << QStringLiteral( + "140000009efb2c79d200f07599f633050100100195c517681800000001000000020000002c9ea991afec1a49f7e72d000b000000b8000000380000000400000099f63305010" + "010012a990000c47323496501a8c000000000807a1268002a2e680100908400000100c5000400000000004e408e518c259b8b329b5fcfdb00903fadad36d7e088813dc9b174" + "b06e08b69f8f46d083cf4118d1eb4062e009147906d9c80759af3eb221f7fbb8e28d402e11ba80e3cf4a101487f87ccce9688daf99dd412e6bcaab6e58f70030ff99323e4c8" + "1824659f7aa89188fadc6402bcb540843e1578cd112d4536fd65c4bd926f754") + << static_cast(1746389891) + << QStringLiteral( + "Tjr8Lv1O0HjJ7U4dOfkA9BdLAnEaCl_TU0GnYGGLTBM06TV9Ggf-fYb7WMqD-Xv758Q1zzSPTeaJctl8au-" + "imM4ACRgl0Y4LqJpLFfgBhkumd4dne2P9oM6qLzMnHfspPq8AFQFHXaiSicu2gSaCwpk36ZK-WX17DaTOkYFncIKl_rSZAkb8OzTpNX0aB_590hUpAf74-" + "TU368A1fgXLw2aunwn0wBNvz0ywFEiAjmD8PfgUzA6IrvkP1eoKoY4A_NNBXnirca7CjWxOoguXRGaHjzq9vrDm8ABTk2o0u29R,Nqmz_4LN1Fj9cNhtyhHTXuV6huLxmsflb_" + "6DR5B8dwk8IMYup0z5AXHhLww0BmZkDKKCWjVehxWvoHkz8FNViV9Oduwv7ZGyHUYs47HUpOIr1Wirp6LEvsxBcDBf-T_XOK945j-z_" + "MtxXiNKqtAuaL8iw7OOIpVnXqIa77yGuOFFW-u2wv1cK1M3s_OqmgEdj0JZfoYbjT6lIEVsSXKMYwwf9zkAjx23K-gqrM8c8nStv4EYT7ZU6o_" + "I0KZ6OJVnCFElYLamz82NIRiPdzyuJcPoslNCXpQV_vWlyGJ0OIoR,2MrkkwMnTNx7HR4FJ6ACh0cQZmdBEB2pM4eQSqpJEC367JtCMzM*") + << 652; + } + + void steamTicket() + { + QFETCH(QString, ticketBytes); + QFETCH(uint32_t, time); + QFETCH(QString, encryptedTicket); + QFETCH(int, encryptedLength); + + auto pair = std::pair{encryptedTicket, encryptedLength}; + QCOMPARE(encryptSteamTicket(ticketBytes, time), pair); + } +}; + +QTEST_MAIN(EncryptedArgTest) +#include "encryptedargtest.moc" diff --git a/external/libphysis b/external/libphysis index 806395e..7fff41a 160000 --- a/external/libphysis +++ b/external/libphysis @@ -1 +1 @@ -Subproject commit 806395eb4c6e76f9fbe8fb7ed4e2d864bdb1c522 +Subproject commit 7fff41a808ce28553d4e16c6b3d0d9c9c5f3832d diff --git a/launcher/CMakeLists.txt b/launcher/CMakeLists.txt index a83c722..83261ea 100644 --- a/launcher/CMakeLists.txt +++ b/launcher/CMakeLists.txt @@ -51,6 +51,7 @@ target_sources(astra_static PRIVATE include/processlogger.h include/squareenixlogin.h include/steamapi.h + include/crtrand.h src/accountmanager.cpp src/assetupdater.cpp @@ -70,7 +71,8 @@ target_sources(astra_static PRIVATE src/patcher.cpp src/processlogger.cpp src/squareenixlogin.cpp - src/steamapi.cpp) + src/steamapi.cpp + src/crtrand.cpp) target_include_directories(astra_static PUBLIC include) target_link_libraries(astra_static PUBLIC physis diff --git a/launcher/include/crtrand.h b/launcher/include/crtrand.h new file mode 100644 index 0000000..dbf5f14 --- /dev/null +++ b/launcher/include/crtrand.h @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: 2025 Joshua Goins +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +class CrtRand +{ +public: + explicit CrtRand(uint32_t seed); + + uint32_t next(); + +private: + uint32_t seed; +}; diff --git a/launcher/include/encryptedarg.h b/launcher/include/encryptedarg.h index 9317503..49963fb 100644 --- a/launcher/include/encryptedarg.h +++ b/launcher/include/encryptedarg.h @@ -5,4 +5,5 @@ #include -QString encryptGameArg(const QString &arg); \ No newline at end of file +QString encryptGameArg(const QString &arg); +std::pair encryptSteamTicket(QString ticket, qint64 time); diff --git a/launcher/include/launchercore.h b/launcher/include/launchercore.h index 0c31aa3..3b322d4 100755 --- a/launcher/include/launchercore.h +++ b/launcher/include/launchercore.h @@ -75,9 +75,6 @@ public: LauncherCore(); ~LauncherCore() override; - /// Initializes the Steamworks API. - void initializeSteam(); - /// Begins the login process. /// It's designed to be opaque as possible to the caller. /// \note The login process is asynchronous. @@ -130,6 +127,7 @@ public: [[nodiscard]] AccountManager *accountManager(); [[nodiscard]] Headline *headline() const; [[nodiscard]] QString cachedLogoImage() const; + [[nodiscard]] SteamAPI *steamApi() const; /** * @brief Opens the official launcher. Useful if Astra decides not to work that day! diff --git a/launcher/include/steamapi.h b/launcher/include/steamapi.h index e55533f..5bf3634 100644 --- a/launcher/include/steamapi.h +++ b/launcher/include/steamapi.h @@ -3,6 +3,8 @@ #pragma once +#include +#include #include class LauncherCore; @@ -12,7 +14,10 @@ class SteamAPI : public QObject public: explicit SteamAPI(QObject *parent = nullptr); - void setLauncherMode(bool isLauncher); + QCoro::Task<> initialize(); + QCoro::Task<> shutdown(); + QCoro::Task> getTicket(); - [[nodiscard]] bool isDeck() const; -}; \ No newline at end of file +private: + QNetworkAccessManager m_qnam; +}; diff --git a/launcher/src/crtrand.cpp b/launcher/src/crtrand.cpp new file mode 100644 index 0000000..1efa4e4 --- /dev/null +++ b/launcher/src/crtrand.cpp @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: 2025 Joshua Goins +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "crtrand.h" + +CrtRand::CrtRand(const uint32_t seed) +{ + this->seed = seed; +} + +uint32_t CrtRand::next() +{ + this->seed = 0x343FD * this->seed + 0x269EC3; + return ((this->seed >> 16) & 0xFFFF) & 0x7FFF; +} diff --git a/launcher/src/encryptedarg.cpp b/launcher/src/encryptedarg.cpp index e7cc57b..0e3a43d 100644 --- a/launcher/src/encryptedarg.cpp +++ b/launcher/src/encryptedarg.cpp @@ -2,7 +2,9 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "encryptedarg.h" +#include "crtrand.h" +#include #include #if defined(Q_OS_MAC) @@ -83,4 +85,83 @@ QString encryptGameArg(const QString &arg) physis_blowfish_free(blowfish); return QStringLiteral("//**sqex0003%1%2**//").arg(base64, QString(QLatin1Char(checksum))); -} \ No newline at end of file +} + +// Based off of the XIVQuickLauncher implementation +constexpr auto SQEX_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_"; +constexpr int SPLIT_SIZE = 300; + +QStringList intoChunks(const QString &str, const int maxChunkSize) +{ + QStringList chunks; + for (int i = 0; i < str.length(); i += maxChunkSize) { + chunks.push_back(str.mid(i, std::min(static_cast(maxChunkSize), str.length() - i))); + } + + return chunks; +} + +std::pair encryptSteamTicket(QString ticket, qint64 time) +{ + // Round the time down + time -= 5; + time -= time % 60; + + auto ticketString = ticket.remove(QLatin1Char('-')).toLower(); + auto rawTicketBytes = ticketString.toLatin1(); + rawTicketBytes.append('\0'); + + ushort ticketSum = 0; + for (const auto b : rawTicketBytes) { + ticketSum += b; + } + + QByteArray binaryWriter; + binaryWriter.append(reinterpret_cast(&ticketSum), sizeof(ushort)); + binaryWriter.append(rawTicketBytes); + + const int castTicketSum = static_cast(ticketSum); + const auto seed = time ^ castTicketSum; + auto rand = CrtRand(seed); + + const auto numRandomBytes = (static_cast(rawTicketBytes.length() + 9) & 0xFFFFFFFFFFFFFFF8) - 2 - static_cast(rawTicketBytes.length()); + auto garbage = QByteArray(); + garbage.resize(numRandomBytes); + + uint badSum = *reinterpret_cast(binaryWriter.data()); + + for (auto i = 0u; i < numRandomBytes; i++) { + const auto randChar = SQEX_ALPHABET[static_cast(badSum + rand.next()) & 0x3F]; + garbage[i] = static_cast(randChar); + badSum += randChar; + } + + binaryWriter.append(garbage); + + char blowfishKey[17]{}; + sprintf(blowfishKey, "%08x#un@e=x>", time); + + binaryWriter.remove(0, 4); + binaryWriter.insert(0, reinterpret_cast(&badSum), sizeof(uint)); + + // swap first two bytes + auto finalBytes = binaryWriter; + std::swap(finalBytes[0], finalBytes[1]); + + SteamTicketBlowfish *blowfish = miscel_steamticket_blowfish_initialize(reinterpret_cast(blowfishKey), 16); + + miscel_steamticket_blowfish_encrypt(blowfish, reinterpret_cast(finalBytes.data()), finalBytes.size()); + Q_ASSERT(finalBytes.length() % 8 == 0); + + miscel_steamticket_physis_blowfish_free(blowfish); + + auto encoded = finalBytes.toBase64(QByteArray::Base64Option::Base64UrlEncoding | QByteArray::Base64Option::KeepTrailingEquals); + encoded.replace('+', '-'); + encoded.replace('/', '_'); + encoded.replace('=', '*'); + + const auto parts = intoChunks(QString::fromLatin1(encoded), SPLIT_SIZE); + const auto finalString = parts.join(QLatin1Char(',')); + + return {finalString, finalString.length() - (parts.length() - 1)}; +} diff --git a/launcher/src/launchercore.cpp b/launcher/src/launchercore.cpp index 32db888..28149ed 100755 --- a/launcher/src/launchercore.cpp +++ b/launcher/src/launchercore.cpp @@ -45,6 +45,7 @@ LauncherCore::LauncherCore() m_profileManager = new ProfileManager(this); m_accountManager = new AccountManager(this); m_runner = new GameRunner(*this, this); + m_steamApi = new SteamAPI(this); connect(m_accountManager, &AccountManager::accountAdded, this, &LauncherCore::fetchAvatar); connect(m_accountManager, &AccountManager::accountLodestoneIdChanged, this, &LauncherCore::fetchAvatar); @@ -79,12 +80,6 @@ LauncherCore::~LauncherCore() m_config->save(); } -void LauncherCore::initializeSteam() -{ - m_steamApi = new SteamAPI(this); - m_steamApi->setLauncherMode(true); -} - void LauncherCore::login(Profile *profile, const QString &username, const QString &password, const QString &oneTimePassword) { Q_ASSERT(profile != nullptr); @@ -445,6 +440,11 @@ QString LauncherCore::cachedLogoImage() const return m_cachedLogoImage; } +SteamAPI *LauncherCore::steamApi() const +{ + return m_steamApi; +} + #ifdef BUILD_SYNC SyncManager *LauncherCore::syncManager() const { @@ -480,10 +480,6 @@ QCoro::Task<> LauncherCore::beginLogin(LoginInformation &info) Q_EMIT stageChanged(i18n("Launching game...")); - if (isSteam()) { - m_steamApi->setLauncherMode(false); - } - m_runner->beginGameExecutable(*info.profile, auth); } diff --git a/launcher/src/main.cpp b/launcher/src/main.cpp index d20bc45..e60c641 100755 --- a/launcher/src/main.cpp +++ b/launcher/src/main.cpp @@ -133,11 +133,6 @@ int main(int argc, char *argv[]) QQmlApplicationEngine engine; - const auto core = engine.singletonInstance(QStringLiteral("zone.xiv.astra"), QStringLiteral("LauncherCore")); - if (parser.isSet(steamOption)) { - core->initializeSteam(); - } - engine.rootContext()->setContextObject(new KLocalizedContext(&engine)); QObject::connect(&engine, &QQmlApplicationEngine::quit, &app, &QCoreApplication::quit); diff --git a/launcher/src/squareenixlogin.cpp b/launcher/src/squareenixlogin.cpp index 0233014..e27a9ed 100644 --- a/launcher/src/squareenixlogin.cpp +++ b/launcher/src/squareenixlogin.cpp @@ -232,9 +232,13 @@ QCoro::Task> SquareEnixLogin::getStor if (m_info->profile->account()->config()->license() == Account::GameLicense::WindowsSteam) { query.addQueryItem(QStringLiteral("issteam"), QString::number(1)); - // TODO: get steam ticket information from steam api - query.addQueryItem(QStringLiteral("session_ticket"), QString::number(1)); - query.addQueryItem(QStringLiteral("ticket_size"), QString::number(1)); + // initialize the steam api + co_await m_launcher.steamApi()->initialize(); + + // grab an auth ticket + auto [ticket, ticketSize] = co_await m_launcher.steamApi()->getTicket(); + query.addQueryItem(QStringLiteral("session_ticket"), ticket); + query.addQueryItem(QStringLiteral("ticket_size"), QString::number(ticketSize)); } QUrl url; @@ -251,22 +255,9 @@ QCoro::Task> SquareEnixLogin::getStor const auto reply = m_launcher.mgr()->get(request); co_await reply; + m_username = m_info->username; + const QString str = QString::fromUtf8(reply->readAll()); - - // fetches Steam username - if (m_info->profile->account()->config()->license() == Account::GameLicense::WindowsSteam) { - const static QRegularExpression re(QStringLiteral(R"lit(.*)""\/>)lit")); - const QRegularExpressionMatch match = re.match(str); - - if (match.hasMatch()) { - m_username = match.captured(1); - } else { - Q_EMIT m_launcher.loginError(i18n("Could not get Steam username, have you attached your account?")); - } - } else { - m_username = m_info->username; - } - const static QRegularExpression re(QStringLiteral(R"lit(\t<\s*input .* name="_STORED_" value="(?.*)">)lit")); const QRegularExpressionMatch match = re.match(str); if (match.hasMatch()) { diff --git a/launcher/src/steamapi.cpp b/launcher/src/steamapi.cpp index 2084db9..610cc38 100644 --- a/launcher/src/steamapi.cpp +++ b/launcher/src/steamapi.cpp @@ -2,23 +2,50 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "steamapi.h" - +#include "encryptedarg.h" #include "launchercore.h" +#include + SteamAPI::SteamAPI(QObject *parent) : QObject(parent) { - // TODO: stub } -void SteamAPI::setLauncherMode(bool isLauncher) +QCoro::Task<> SteamAPI::initialize() { - Q_UNUSED(isLauncher) - // TODO: stub + QUrl url; + url.setScheme(QStringLiteral("http")); + url.setHost(QStringLiteral("127.0.0.1")); + url.setPort(50481); + url.setPath(QStringLiteral("/init")); + + Q_UNUSED(co_await m_qnam.post(QNetworkRequest(url), QByteArray{})) } -bool SteamAPI::isDeck() const +QCoro::Task<> SteamAPI::shutdown() { - // TODO: stub - return false; + QUrl url; + url.setScheme(QStringLiteral("http")); + url.setHost(QStringLiteral("127.0.0.1")); + url.setPort(50481); + url.setPath(QStringLiteral("/shutdown")); + + Q_UNUSED(co_await m_qnam.post(QNetworkRequest(url), QByteArray{})) +} + +QCoro::Task> SteamAPI::getTicket() +{ + QUrl url; + url.setScheme(QStringLiteral("http")); + url.setHost(QStringLiteral("127.0.0.1")); + url.setPort(50481); + url.setPath(QStringLiteral("/ticket")); + + const auto reply = co_await m_qnam.get(QNetworkRequest(url)); + const auto ticketBytes = reply->readAll(); + + const QJsonDocument document = QJsonDocument::fromJson(ticketBytes); + + co_return encryptSteamTicket(document[QStringLiteral("ticket")].toString(), document[QStringLiteral("time")].toInteger()); } diff --git a/launcher/ui/Setup/AddSquareEnix.qml b/launcher/ui/Setup/AddSquareEnix.qml index 0ddbd5a..007d783 100644 --- a/launcher/ui/Setup/AddSquareEnix.qml +++ b/launcher/ui/Setup/AddSquareEnix.qml @@ -47,13 +47,6 @@ FormCard.FormCardPage { description: i18n("If the account holds multiple licenses, choose the preferred one.") model: ["Windows", "Steam", "macOS"] text: i18n("License") - - onCurrentIndexChanged: { - if (currentIndex === 1) { - currentIndex = 0; - errorDialog.open(); - } - } } FormCard.FormDelegateSeparator { above: licenseField @@ -87,12 +80,4 @@ FormCard.FormCardPage { } } } - Kirigami.PromptDialog { - id: errorDialog - - showCloseButton: false - standardButtons: Kirigami.Dialog.Ok - title: i18n("Steam Warning") - subtitle: i18n("Steam linked Square Enix accounts are not currently supported. You will have to use another launcher that supports these, such as the official launcher or XIVLauncher.Core.") - } -} \ No newline at end of file +}