1
Fork 0
mirror of https://github.com/redstrate/Astra.git synced 2025-05-05 18:27:46 +00:00

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.
This commit is contained in:
Joshua Goins 2025-05-04 16:57:29 -04:00 committed by GitHub
parent 74a3c5f5d2
commit ea262ddfb7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 286 additions and 69 deletions

View file

@ -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).

View file

@ -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-"
)

44
autotests/crtrandtest.cpp Normal file
View file

@ -0,0 +1,44 @@
// SPDX-FileCopyrightText: 2025 Joshua Goins <josh@redstrate.com>
// SPDX-License-Identifier: GPL-3.0-or-later
#include <QtTest/QtTest>
#include "crtrand.h"
class CrtRandTest : public QObject
{
Q_OBJECT
private Q_SLOTS:
void randomSeed_data()
{
QTest::addColumn<uint32_t>("seed");
QTest::addColumn<uint32_t>("value1");
QTest::addColumn<uint32_t>("value2");
QTest::addColumn<uint32_t>("value3");
QTest::addColumn<uint32_t>("value4");
QTest::addRow("test 1") << static_cast<uint32_t>(5050) << static_cast<uint32_t>(16529) << static_cast<uint32_t>(23363) << static_cast<uint32_t>(25000)
<< static_cast<uint32_t>(18427);
QTest::addRow("test 2") << static_cast<uint32_t>(19147) << static_cast<uint32_t>(29796) << static_cast<uint32_t>(24416) << static_cast<uint32_t>(1377)
<< static_cast<uint32_t>(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"

View file

@ -0,0 +1,50 @@
// SPDX-FileCopyrightText: 2025 Joshua Goins <josh@redstrate.com>
// SPDX-License-Identifier: GPL-3.0-or-later
#include <QtTest/QtTest>
#include "encryptedarg.h"
class EncryptedArgTest : public QObject
{
Q_OBJECT
private Q_SLOTS:
void steamTicket_data()
{
QTest::addColumn<QString>("ticketBytes");
QTest::addColumn<uint32_t>("time");
QTest::addColumn<QString>("encryptedTicket");
QTest::addColumn<int>("encryptedLength");
QTest::addRow("real ticket")
<< QStringLiteral(
"140000009efb2c79d200f07599f633050100100195c517681800000001000000020000002c9ea991afec1a49f7e72d000b000000b8000000380000000400000099f63305010"
"010012a990000c47323496501a8c000000000807a1268002a2e680100908400000100c5000400000000004e408e518c259b8b329b5fcfdb00903fadad36d7e088813dc9b174"
"b06e08b69f8f46d083cf4118d1eb4062e009147906d9c80759af3eb221f7fbb8e28d402e11ba80e3cf4a101487f87ccce9688daf99dd412e6bcaab6e58f70030ff99323e4c8"
"1824659f7aa89188fadc6402bcb540843e1578cd112d4536fd65c4bd926f754")
<< static_cast<uint32_t>(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"

2
external/libphysis vendored

@ -1 +1 @@
Subproject commit 806395eb4c6e76f9fbe8fb7ed4e2d864bdb1c522
Subproject commit 7fff41a808ce28553d4e16c6b3d0d9c9c5f3832d

View file

@ -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

View file

@ -0,0 +1,17 @@
// SPDX-FileCopyrightText: 2025 Joshua Goins <josh@redstrate.com>
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <cstdint>
class CrtRand
{
public:
explicit CrtRand(uint32_t seed);
uint32_t next();
private:
uint32_t seed;
};

View file

@ -5,4 +5,5 @@
#include <QString>
QString encryptGameArg(const QString &arg);
QString encryptGameArg(const QString &arg);
std::pair<QString, int> encryptSteamTicket(QString ticket, qint64 time);

View file

@ -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!

View file

@ -3,6 +3,8 @@
#pragma once
#include <QCoroTask>
#include <QNetworkAccessManager>
#include <QObject>
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<std::pair<QString, int>> getTicket();
[[nodiscard]] bool isDeck() const;
};
private:
QNetworkAccessManager m_qnam;
};

15
launcher/src/crtrand.cpp Normal file
View file

@ -0,0 +1,15 @@
// SPDX-FileCopyrightText: 2025 Joshua Goins <josh@redstrate.com>
// 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;
}

View file

@ -2,7 +2,9 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "encryptedarg.h"
#include "crtrand.h"
#include <QDebug>
#include <physis.hpp>
#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)));
}
}
// 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<qlonglong>(maxChunkSize), str.length() - i)));
}
return chunks;
}
std::pair<QString, int> 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<const char *>(&ticketSum), sizeof(ushort));
binaryWriter.append(rawTicketBytes);
const int castTicketSum = static_cast<short>(ticketSum);
const auto seed = time ^ castTicketSum;
auto rand = CrtRand(seed);
const auto numRandomBytes = (static_cast<ulong>(rawTicketBytes.length() + 9) & 0xFFFFFFFFFFFFFFF8) - 2 - static_cast<ulong>(rawTicketBytes.length());
auto garbage = QByteArray();
garbage.resize(numRandomBytes);
uint badSum = *reinterpret_cast<uint32_t *>(binaryWriter.data());
for (auto i = 0u; i < numRandomBytes; i++) {
const auto randChar = SQEX_ALPHABET[static_cast<int>(badSum + rand.next()) & 0x3F];
garbage[i] = static_cast<char>(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<const char *>(&badSum), sizeof(uint));
// swap first two bytes
auto finalBytes = binaryWriter;
std::swap(finalBytes[0], finalBytes[1]);
SteamTicketBlowfish *blowfish = miscel_steamticket_blowfish_initialize(reinterpret_cast<uint8_t *>(blowfishKey), 16);
miscel_steamticket_blowfish_encrypt(blowfish, reinterpret_cast<uint8_t *>(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)};
}

View file

@ -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);
}

View file

@ -133,11 +133,6 @@ int main(int argc, char *argv[])
QQmlApplicationEngine engine;
const auto core = engine.singletonInstance<LauncherCore *>(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);

View file

@ -232,9 +232,13 @@ QCoro::Task<std::optional<SquareEnixLogin::StoredInfo>> 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<std::optional<SquareEnixLogin::StoredInfo>> 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(<input name=""sqexid"" type=""hidden"" value=""(?<sqexid>.*)""\/>)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="(?<stored>.*)">)lit"));
const QRegularExpressionMatch match = re.match(str);
if (match.hasMatch()) {

View file

@ -2,23 +2,50 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "steamapi.h"
#include "encryptedarg.h"
#include "launchercore.h"
#include <QCoroNetwork>
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<std::pair<QString, int>> 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());
}

View file

@ -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.")
}
}
}