Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ add_library(OpenSteamTool SHARED

# Shared utilities
Utils/Tickets/AppTicket.cpp
Utils/Tickets/TicketAuthority.cpp
Utils/Tickets/LegacyCDKey.cpp
Utils/Tickets/EticketClient.cpp
Utils/Config/Config.cpp
Expand Down
60 changes: 31 additions & 29 deletions src/Hook/Hooks_IPC_ISteamUser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
#include "PendingAPICalls.h"
#include "Utils/Tickets/AppTicket.h"
#include "Utils/Tickets/EticketClient.h"
#include "Utils/Tickets/TicketAuthority.h"
#include "Pipe/PipeManager.h"
#include "Pipe/Features/DenuvoAuth/DenuvoAuth.h"
#include "Utils/Logging/Log.h"
Expand Down Expand Up @@ -56,40 +57,41 @@ namespace {
LOG_IPC_DEBUG("IClientUser::GetAppOwnershipTicketExtendedData:{}", req.DebugString());
if (req.cbMaxTicket() < 0) return;

AppTicket::AppOwnershipTicket ticket{};
AppId_t appId = req.unAppID() == kOnlineFixAppId ? Hooks_Misc::ResolveAppId() : req.unAppID();
AppTicket::AppTicketSource ticketSource;
if (PipeManager::DenuvoAuth::IsAuthorizedPipe(pipe)) {
ticketSource = AppTicket::AppTicketSource::CredentialStoreOnly;
} else {
// Outside the auth window: prefer credential-store ticket (pool SteamID)
// over ForgeOnly (which uses app 7's ticket and carries the real SteamID).
// When the 858 network spoof is also active, both paths must agree on the
// same SteamID or Denuvo cross-checks them and rejects (error 54).
LOG_IPC_DEBUG("IClientUser::GetAppOwnershipTicketExtendedData: AppId={} not in authorization window, credential store preferred", appId);
ticketSource = AppTicket::AppTicketSource::CredentialStoreThenForge;
}
if (!AppTicket::GetAppOwnershipTicket(appId, ticket, ticketSource)) return;

if (ticket.data.size() > static_cast<size_t>(req.cbMaxTicket())) {

// The ticket authority owns this decision: it answers with a validated
// stored ticket when one exists and otherwise forges from a locally
// cached base, recording why. The same decision drives the SteamID
// spoof, so the bytes served and the identity reported cannot disagree;
// "nothing servable" here is the launch-failure class seen as error 54.
const bool authorized = PipeManager::DenuvoAuth::IsAuthorizedPipe(pipe);
const TicketAuthority::Decision decision = TicketAuthority::Resolve(appId);
if (!decision.servable) {
LOG_IPC_WARN("IClientUser::GetAppOwnershipTicketExtendedData: AppId={} authorized={} - {}",
appId, authorized, decision.reason);
return;
}

if (decision.bytes.size() > static_cast<size_t>(req.cbMaxTicket())) {
LOG_IPC_WARN("IClientUser::GetAppOwnershipTicketExtendedData: AppId={} ticket too large ({} bytes) for buffer ({} bytes)",
appId, ticket.data.size(), req.cbMaxTicket());
appId, decision.bytes.size(), req.cbMaxTicket());
return;
}

GetAppOwnershipTicketExtendedDataResp resp{pWrite, static_cast<size_t>(req.cbMaxTicket())};
if (!resp.ok()) return;

resp.set_returnValue(ticket.totalSize);
if (!resp.set_pTicket(ticket.data)) return;
resp.set_piAppId(ticket.appIdOffset);
resp.set_piSteamId(ticket.steamIdOffset);
resp.set_piSignature(ticket.signatureOffset);
resp.set_pcbSignature(ticket.signatureSize);

LOG_IPC_DEBUG("IClientUser::GetAppOwnershipTicketExtendedData: AppId={} {}",
appId,resp.DebugString());
resp.set_returnValue(decision.totalSize);
if (!resp.set_pTicket(decision.bytes)) return;
resp.set_piAppId(decision.appIdOffset);
resp.set_piSteamId(decision.steamIdOffset);
resp.set_piSignature(decision.signatureOffset);
resp.set_pcbSignature(decision.signatureSize);

LOG_IPC_INFO("IClientUser::GetAppOwnershipTicketExtendedData: AppId={} served {} ({} bytes, base AppId {}, owner {}{})",
appId, TicketAuthority::ToString(decision.provenance), decision.bytes.size(),
decision.sourceApp, decision.ownerSteamId,
decision.mixedIdentity ? ", mixed identity" : "");
}

// [Post-Handler]: IClientUser::RequestEncryptedAppTicket
Expand All @@ -111,10 +113,10 @@ namespace {
std::span<const uint8_t> nonce;
if (req.ok()) nonce = req.pData();
// Whatever account the registry's current static ticket already
// belongs to (0 if none) — lets the backend pin the mint to that
// SAME account instead of risking a different pool pick.
// belongs to (0 if none or stale) - lets the backend pin the mint
// to that SAME account instead of risking a different pool pick.
const uint64_t existingSteamId = AppTicket::ExtractSteamIdFromTicketBytes(
AppTicket::GetAppOwnershipTicketFromCredentialStore(appId));
AppTicket::GetValidatedAppOwnershipTicketFromCredentialStore(appId));
// Mint a fresh eticket whenever the credential store already has a ticket
// for this app (existingSteamId != 0). The minted eticket is pinned to
// the same pool account via existingSteamId, which matches GetSteamID's
Expand Down
2 changes: 1 addition & 1 deletion src/Hook/Hooks_NetPacket.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -640,7 +640,7 @@ namespace Hooks_NetPacket_OwnershipTicket {
// here caused a cross-check mismatch → 012 even when the SteamID was the
// same account. Only mint from the backend when the credential store has
// no valid ticket (existingSteamId == 0).
auto stored = AppTicket::GetAppOwnershipTicketFromCredentialStore(resp.app_id());
auto stored = AppTicket::GetValidatedAppOwnershipTicketFromCredentialStore(resp.app_id());
const uint64_t existingSteamId = AppTicket::ExtractSteamIdFromTicketBytes(stored);

std::vector<uint8_t> ticketBytes;
Expand Down
43 changes: 43 additions & 0 deletions src/OSTPlatform/Windows/SteamCredentialStore.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ namespace {
constexpr const wchar_t* kValueSteamId = L"SteamID";
constexpr const wchar_t* kValueAppTicket = L"AppTicket";
constexpr const wchar_t* kValueEncryptedTicket = L"ETicket";
constexpr const wchar_t* kAppsRootKeyPath = L"Software\\Valve\\Steam\\Apps";
constexpr const wchar_t* kActiveProcessKeyPath = L"Software\\Valve\\Steam\\ActiveProcess";
constexpr const wchar_t* kValueActiveUser = L"ActiveUser";
constexpr const wchar_t* kValueUniverse = L"Universe";
Expand Down Expand Up @@ -192,6 +193,48 @@ Status WriteSteamId(uint32_t appId, uint64_t steamId) {
reinterpret_cast<const BYTE*>(value.c_str()), bytes));
}

// Subkey scan of HKCU\Software\Valve\Steam\Apps. Steam itself writes one key
// per app that has credentials (tickets / SteamID), so this doubles as "apps
// this account has locally usable ticket material for". Bounded in both entry
// count and name length so a corrupted hive cannot stall the caller.
Status EnumerateAppIds(std::vector<uint32_t>& appIds) {
appIds.clear();

Windows::UniqueRegKey key;
LSTATUS status = RegOpenKeyExW(HKEY_CURRENT_USER, kAppsRootKeyPath, 0,
KEY_ENUMERATE_SUB_KEYS | KEY_QUERY_VALUE, key.put());
if (status != ERROR_SUCCESS) {
const auto mapped = FromLStatus(status);
if (mapped == Status::NotFound) {
OSTP_LOG_DEBUG("SteamCredentialStore: Apps key missing status={}", status);
} else {
OSTP_LOG_WARN("SteamCredentialStore: failed to open Apps key status={}", status);
}
return mapped;
}

constexpr DWORD kMaxEntries = 4096;
constexpr DWORD kMaxNameChars = 64;
for (DWORD index = 0; index < kMaxEntries; ++index) {
wchar_t name[kMaxNameChars];
DWORD nameChars = static_cast<DWORD>(sizeof(name) / sizeof(name[0]));
status = RegEnumKeyExW(key.get(), index, name, &nameChars,
nullptr, nullptr, nullptr, nullptr);
if (status == ERROR_NO_MORE_ITEMS) break;
if (status != ERROR_SUCCESS) {
OSTP_LOG_WARN("SteamCredentialStore: Apps enumeration stopped at index={} status={}", index, status);
break;
}

const auto parsed = Numbers::ParseUInt32(std::wstring_view(name, nameChars));
if (!parsed || *parsed == 0) continue;
appIds.push_back(*parsed);
}

OSTP_LOG_DEBUG("SteamCredentialStore: enumerated {} app credential keys", appIds.size());
return Status::Ok;
}

Status GetActiveUser(uint32_t& accountId, std::wstring& universe) {
DWORD activeUser = 0;
DWORD bytes = sizeof(activeUser);
Expand Down
9 changes: 9 additions & 0 deletions src/OSTPlatform/include/SteamCredentialStore.h
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,13 @@ namespace OSTPlatform::SteamCredentialStore {

Status GetActiveUser(uint32_t& accountId, std::wstring& universe);

// Enumerate the app ids that have a per-app credential key in the store.
// Windows backend: subkeys of HKCU\Software\Valve\Steam\Apps whose names
// parse as decimal app ids (anything else is skipped). Returns Ok with an
// empty vector when the root key exists but holds no app keys, and
// NotFound when the root key is missing. Used to find a signed ticket to
// forge a target app's ownership ticket from when the usual sources are
// absent.
Status EnumerateAppIds(std::vector<uint32_t>& appIds);

} // namespace OSTPlatform::SteamCredentialStore
35 changes: 30 additions & 5 deletions src/Utils/Config/Config.cpp
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#include "Config.h"
#include "Utils/Logging/Log.h"
#include "Utils/SteamMetadata/ManifestClient.h"
#include "Utils/Tickets/TicketAuthority.h"

#include <toml++/toml.hpp>

Expand All @@ -19,6 +20,7 @@ namespace {
std::vector<std::string> remoteUrlTemplates;
bool statsEnableApi = true;
bool updateEnabled = true;
TicketPolicy ticketPolicy;
DonateSettings donate;
std::vector<InjectDll> injectDlls;
CloudSettings cloud;
Expand Down Expand Up @@ -56,6 +58,7 @@ namespace {
remoteUrlTemplates = snapshot.remoteUrlTemplates;
statsEnableApi = snapshot.statsEnableApi;
updateEnabled = snapshot.updateEnabled;
ticketPolicy = snapshot.ticketPolicy;
donate = snapshot.donate;
injectDlls = snapshot.injectDlls;
cloudEnabled = snapshot.cloud.enabled;
Expand All @@ -70,12 +73,18 @@ namespace {
}

LoadResult ApplySnapshotLocked(const Snapshot& snapshot) {
std::lock_guard lock(g_mutex);
LoadResult result;
result.luaPathsChanged = luaPaths != snapshot.luaPaths;
ApplySnapshot(snapshot);
g_loadedOnce = true;
result.applied = true;
{
std::lock_guard lock(g_mutex);
result.luaPathsChanged = luaPaths != snapshot.luaPaths;
ApplySnapshot(snapshot);
g_loadedOnce = true;
result.applied = true;
}
// Ticket decisions depend on this policy: drop the cache so the next
// launch re-evaluates. Called after the config lock is released so the
// lock order stays one-way (ticket authority -> config).
TicketAuthority::Reset();
return result;
}

Expand Down Expand Up @@ -223,6 +232,17 @@ namespace {
snapshot.cloud.library = *val;
}

// [tickets]
if (auto tickets = tbl["tickets"].as_table()) {
if (auto v = (*tickets)["identity_mode"].value<std::string>()) {
// "best_effort" (default) serves the best available bytes even
// when identities mix; "strict" refuses such answers instead.
if (*v == "strict") snapshot.ticketPolicy.allowMixedIdentity = false;
else if (*v == "best_effort") snapshot.ticketPolicy.allowMixedIdentity = true;
else LOG_WARN("[tickets] identity_mode \"{}\" unknown (use \"best_effort\" or \"strict\")", *v);
}
}

ApplyManifestProvider(snapshot.manifestProvider);
LoadResult result = ApplySnapshotLocked(snapshot);
LOG_INFO("Config loaded: manifest.url={} log.level={} lua.paths={} stats.enable_api={} remote.url_templates={}",
Expand Down Expand Up @@ -299,6 +319,11 @@ namespace {
return updateEnabled;
}

TicketPolicy GetTicketPolicy() {
std::lock_guard lock(g_mutex);
return ticketPolicy;
}

CloudSettings GetCloudSettings() {
std::lock_guard lock(g_mutex);
return {
Expand Down
11 changes: 11 additions & 0 deletions src/Utils/Config/Config.h
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ namespace Config {
bool allGames = false; // false: only Lua-unlocked games
};

// [tickets] - how the ownership-ticket reply is decided (TicketAuthority).
struct TicketPolicy {
bool allowMixedIdentity = true; // identity_mode "best_effort" (default) serves the
// best available bytes; "strict" refuses answers
// whose identities would mix
};

struct CloudSettings {
bool enabled = false;
std::string library;
Expand All @@ -46,6 +53,7 @@ namespace Config {
CloudSettings GetCloudSettings();
bool GetStatsEnableApi();
bool GetUpdateEnabled();
TicketPolicy GetTicketPolicy();

// [donate] — contribute manifest request codes for depots this account owns.
struct DonateSettings {
Expand Down Expand Up @@ -83,6 +91,9 @@ namespace Config {
// [update] - self-update check on startup (staged for next Steam launch).
inline bool updateEnabled = true;

// [tickets] - ownership-ticket answer policy (see TicketAuthority).
inline TicketPolicy ticketPolicy;

// [donate] - mint manifest request codes on request for depots this account
// owns. Codes are bound to (depot, manifest) and rotate within minutes, so
// they are minted on demand and sent straight on, never stored. The caps
Expand Down
4 changes: 4 additions & 0 deletions src/Utils/Config/ConfigFileWatcher.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
#include "Utils/Config/LuaFileWatcher.h"
#include "Utils/Config/ConfigFileWatcher.h"
#include "Utils/Logging/Log.h"
#include "Utils/Tickets/TicketAuthority.h"
#include "OSTPlatform/include/DirectoryWatch.h"

#include <atomic>
Expand Down Expand Up @@ -60,6 +61,9 @@ void RestartLuaWatcher() {
LuaConfig::ReloadDirectories(watchDirs);
LuaFileWatcher::Start(watchDirs);

// The managed app set changed: cached ticket decisions may no longer apply.
TicketAuthority::Reset();

Hooks_Package::NotifyLicenseChanged();
LOG_INFO("Lua directories refreshed after config reload: {}", static_cast<uint32_t>(watchDirs.size()));
}
Expand Down
5 changes: 5 additions & 0 deletions src/Utils/Config/LuaConfig.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
#include "Utils/Config/LuaConfig.h"
#include "Utils/SteamMetadata/StatsClient.h"
#include "Utils/Tickets/AppTicket.h"
#include "Utils/Tickets/TicketAuthority.h"

#include <lua.hpp>

Expand Down Expand Up @@ -424,6 +425,8 @@ namespace LuaConfig{
if (!AppTicket::WriteAppOwnershipTicket(appId, binary))
return luaL_error(L, "setAppTicket: failed to write credential store");

TicketAuthority::NoteImport(appId, TicketAuthority::ImportKind::AppTicket, binary);

return 0;
}

Expand Down Expand Up @@ -458,6 +461,8 @@ namespace LuaConfig{
if (!AppTicket::WriteEncryptedTicket(appId, binary))
return luaL_error(L, "setETicket: failed to write credential store");

TicketAuthority::NoteImport(appId, TicketAuthority::ImportKind::ETicket, binary);

return 0;
}

Expand Down
4 changes: 4 additions & 0 deletions src/Utils/Config/LuaFileWatcher.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
#include "Utils/Config/LuaConfig.h"
#include "Utils/CloudRedirect/CloudRedirectHost.h"
#include "Utils/Logging/Log.h"
#include "Utils/Tickets/TicketAuthority.h"
#include "OSTPlatform/include/DirectoryWatch.h"

#include <algorithm>
Expand Down Expand Up @@ -115,6 +116,9 @@ void ProcessChanges(const std::vector<FileChange>& changes) {
}
}

// App ids may have been added or removed: drop cached ticket decisions.
TicketAuthority::Reset();

Hooks_Package::NotifyLicenseChanged();
CloudRedirectHost::SyncAppSet();
LOG_PACKAGE_DEBUG("Lua refresh completed");
Expand Down
1 change: 1 addition & 0 deletions src/Utils/Logging/LogModules.def
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,4 @@ OST_MOD(SteamUI, "steamui")
OST_MOD(Inject, "inject")
OST_MOD(Pipe, "pipe")
OST_MOD(Platform, "platform")
OST_MOD(Ticket, "ticket")
Loading