diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 3798ab77..c04665d4 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -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 diff --git a/src/Hook/Hooks_IPC_ISteamUser.cpp b/src/Hook/Hooks_IPC_ISteamUser.cpp index ceb5b0f9..df716a81 100644 --- a/src/Hook/Hooks_IPC_ISteamUser.cpp +++ b/src/Hook/Hooks_IPC_ISteamUser.cpp @@ -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" @@ -56,40 +57,39 @@ 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(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(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(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); + 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_DEBUG("IClientUser::GetAppOwnershipTicketExtendedData: AppId={} {}", - appId,resp.DebugString()); + LOG_IPC_DEBUG("IClientUser::GetAppOwnershipTicketExtendedData: AppId={} {} ({})", + appId,resp.DebugString(), TicketAuthority::ToString(decision.provenance)); } // [Post-Handler]: IClientUser::RequestEncryptedAppTicket @@ -111,10 +111,10 @@ namespace { std::span 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 diff --git a/src/Hook/Hooks_NetPacket.cpp b/src/Hook/Hooks_NetPacket.cpp index 1f716429..cbc85cbc 100644 --- a/src/Hook/Hooks_NetPacket.cpp +++ b/src/Hook/Hooks_NetPacket.cpp @@ -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 ticketBytes; diff --git a/src/Hook/Hooks_SteamUI.cpp b/src/Hook/Hooks_SteamUI.cpp index 4d1c4e4f..24948509 100644 --- a/src/Hook/Hooks_SteamUI.cpp +++ b/src/Hook/Hooks_SteamUI.cpp @@ -4,12 +4,186 @@ #include "dllmain.h" #include "steam_messages.pb.h" #include "Utils/HookSupport/VehCommon.h" +#include +#include +#include #include +#include +#include #include #include +#include namespace { + using namespace std::chrono_literals; + constexpr int kMaxRetry = 500; + constexpr auto kRetryInterval = 10ms; + + static std::string_view ExtractFileName(std::string_view path) { + const size_t pos = path.find_last_of("\\/"); + return (pos == std::string_view::npos) ? path : path.substr(pos + 1); + } + + static std::wstring_view ExtractFileNameW(std::wstring_view path) { + const size_t pos = path.find_last_of(L"\\/"); + return (pos == std::wstring_view::npos) ? path : path.substr(pos + 1); + } + + static bool IsSteamClientPath(const char* path) { + if (!path) return false; + std::string_view fn = ExtractFileName(path); + switch (fn.size()) { + case 17: return _strnicmp(fn.data(), "steamclient64.dll", 17) == 0; + case 15: return _strnicmp(fn.data(), "steamclient.dll", 15) == 0; + case 13: return _strnicmp(fn.data(), "steamclient64", 13) == 0; + case 11: return _strnicmp(fn.data(), "steamclient", 11) == 0; + default: return false; + } + } + + static bool IsSteamClientPathW(const wchar_t* path) { + if (!path) return false; + std::wstring_view fn = ExtractFileNameW(path); + switch (fn.size()) { + case 17: return _wcsnicmp(fn.data(), L"steamclient64.dll", 17) == 0; + case 15: return _wcsnicmp(fn.data(), L"steamclient.dll", 15) == 0; + case 13: return _wcsnicmp(fn.data(), L"steamclient64", 13) == 0; + case 11: return _wcsnicmp(fn.data(), L"steamclient", 11) == 0; + default: return false; + } + } + + // Original pointers for system module lookup APIs. While the diversion + // module is active, callers asking for "steamclient64.dll" are handed the + // loaded copy (client_hModule) instead of the untouched original. + static decltype(&GetModuleHandleA) oGetModuleHandleA = &GetModuleHandleA; + static decltype(&GetModuleHandleW) oGetModuleHandleW = &GetModuleHandleW; + static decltype(&GetModuleHandleExA) oGetModuleHandleExA = &GetModuleHandleExA; + static decltype(&GetModuleHandleExW) oGetModuleHandleExW = &GetModuleHandleExW; + + HMODULE WINAPI hkGetModuleHandleA(LPCSTR lpModuleName) + { + if (client_hModule && IsSteamClientPath(lpModuleName)) { + return reinterpret_cast(client_hModule); + } + return oGetModuleHandleA(lpModuleName); + } + + HMODULE WINAPI hkGetModuleHandleW(LPCWSTR lpModuleName) + { + if (client_hModule && IsSteamClientPathW(lpModuleName)) { + return reinterpret_cast(client_hModule); + } + return oGetModuleHandleW(lpModuleName); + } + + BOOL WINAPI hkGetModuleHandleExA(DWORD dwFlags, LPCSTR lpModuleName, HMODULE* phModule) + { + if (client_hModule && !(dwFlags & GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS) && + IsSteamClientPath(lpModuleName)) + { + if (!phModule) { + SetLastError(ERROR_INVALID_PARAMETER); + return FALSE; + } + if ((dwFlags & GET_MODULE_HANDLE_EX_FLAG_PIN) && + (dwFlags & GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT)) + { + SetLastError(ERROR_INVALID_PARAMETER); + return FALSE; + } + if (!(dwFlags & GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT)) { + HMODULE dummy = nullptr; + if (!oGetModuleHandleExA((dwFlags & GET_MODULE_HANDLE_EX_FLAG_PIN) | GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, + reinterpret_cast(client_hModule), &dummy)) + { + *phModule = nullptr; + return FALSE; + } + } + *phModule = reinterpret_cast(client_hModule); + return TRUE; + } + return oGetModuleHandleExA(dwFlags, lpModuleName, phModule); + } + + BOOL WINAPI hkGetModuleHandleExW(DWORD dwFlags, LPCWSTR lpModuleName, HMODULE* phModule) + { + if (client_hModule && !(dwFlags & GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS) && + IsSteamClientPathW(lpModuleName)) + { + if (!phModule) { + SetLastError(ERROR_INVALID_PARAMETER); + return FALSE; + } + if ((dwFlags & GET_MODULE_HANDLE_EX_FLAG_PIN) && + (dwFlags & GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT)) + { + SetLastError(ERROR_INVALID_PARAMETER); + return FALSE; + } + if (!(dwFlags & GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT)) { + HMODULE dummy = nullptr; + if (!oGetModuleHandleExW((dwFlags & GET_MODULE_HANDLE_EX_FLAG_PIN) | GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, + reinterpret_cast(client_hModule), &dummy)) + { + *phModule = nullptr; + return FALSE; + } + } + *phModule = reinterpret_cast(client_hModule); + return TRUE; + } + return oGetModuleHandleExW(dwFlags, lpModuleName, phModule); + } + + // SteamUI loads its client module through this function. While the + // diversion module is active the load is answered with the already-loaded + // copy, so SteamUI and the hook shim agree on one client module. Waits + // (bounded) for hook installation so this can never race the init thread. + HOOK_FUNC(LoadModuleWithPath, void*, const char* path, bool flags) + { + LOG_STEAMUI_INFO("LoadModuleWithPath called with path: {}, flags: {}", + path ? path : "(null)", flags); + + const bool isSteamClient = IsSteamClientPath(path); + + if (isSteamClient) { + bool logged = false; + for (int i = 0; i < kMaxRetry && !g_HooksInstalled.load(std::memory_order_acquire); ++i) { + if (!logged) { + LOG_STEAMUI_DEBUG("LoadModuleWithPath: waiting for hooks to be installed..."); + logged = true; + } + std::this_thread::sleep_for(kRetryInterval); + } + } + + void* h = oLoadModuleWithPath(path, flags); + + if (isSteamClient) { + if (!g_HooksInstalled.load(std::memory_order_acquire)) { + LOG_STEAMUI_WARN("LoadModuleWithPath: hooks initialization timed out after {} ms, aborting diversion", + kMaxRetry * static_cast(kRetryInterval.count())); + return h; + } + + if (client_hModule) { + if (g_IsDiversionActive.load(std::memory_order_acquire)) { + LOG_STEAMUI_INFO("LoadModuleWithPath: diverted {} (original {:p}) -> diversion {:p}", + path ? path : "steamclient64.dll", h, static_cast(client_hModule)); + } else { + LOG_STEAMUI_INFO("LoadModuleWithPath: returned fallback steamclient64.dll ({:p})", + static_cast(client_hModule)); + } + return client_hModule; + } + } + + return h; + } + RESOLVE_FUNC(RepeatedFieldUint32_Add, void, void* field, const uint32* value); CAPTURE_THIS_FUNC(GetAppByID, CSteamApp*, g_pController,void* pThis, AppId_t appId, bool bCreate); @@ -50,7 +224,9 @@ namespace } // Apps to drop from the library: queued off-thread, marked on the UI thread. + // g_hasPendingRemovals lets the per-frame hook skip the lock when idle. std::mutex g_removalMutex; + std::atomic g_hasPendingRemovals{false}; std::vector g_pendingRemovals; std::unordered_set g_removedAppIds; @@ -60,48 +236,63 @@ namespace CAppOverview_Change *pChange, void *optionalCallbackSlot) { oBuildCompleteAppOverviewChange(pController, pChange, optionalCallbackSlot); - std::lock_guard lock(g_removalMutex); - if (pChange && !g_removedAppIds.empty() && oRepeatedFieldUint32_Add) + if (pChange && oRepeatedFieldUint32_Add) { - auto* field = pChange->mutable_removed_appid(); - for (AppId_t appId : g_removedAppIds){ - oRepeatedFieldUint32_Add(field, &appId); + std::lock_guard lock(g_removalMutex); + if (!g_removedAppIds.empty()) + { + auto* field = pChange->mutable_removed_appid(); + for (AppId_t appId : g_removedAppIds) { + oRepeatedFieldUint32_Add(field, &appId); + } + LOG_STEAMUI_DEBUG("BuildCompleteAppOverviewChange: appended {} removed_appid entries", + g_removedAppIds.size()); } - LOG_STEAMUI_DEBUG("BuildCompleteAppOverviewChange: appended {} removed_appid entries", - g_removedAppIds.size()); } } - // Clearing ownership makes ShouldShowAppInLibrary() false (delta drops it, // the full snapshot skips it); MarkAppChange triggers the flush. HOOK_FUNC(CSteamUIAppControllerRunFrame, void *, void *pController) { if (CAPTURE_READY(GetAppByID) && CAPTURE_READY(MarkAppChange)) { - std::vector draining; - { - std::lock_guard lock(g_removalMutex); - draining.swap(g_pendingRemovals); - } - for (AppId_t appId : draining) + if (g_hasPendingRemovals.load(std::memory_order_acquire)) { - if (LuaConfig::IsOwned(appId)) + std::vector draining; { - LOG_STEAMUI_DEBUG("RunFrame: appId {} is owned again, skipping removal", appId); - continue; + std::lock_guard lock(g_removalMutex); + draining.swap(g_pendingRemovals); + g_hasPendingRemovals.store(false, std::memory_order_release); } - if (CSteamApp *pApp = oGetAppByID(g_pController, appId, false)) + if (!draining.empty()) { - // Only remove from the library if it's not already uninstalled - pApp->OwnershipFlags = k_EAppOwnershipFlags_None; - if(pApp->AppStateFlags == k_EAppStateUninstalled){ + std::vector uninstalledAppIds; + for (AppId_t appId : draining) + { + if (LuaConfig::IsOwned(appId)) + { + LOG_STEAMUI_DEBUG("RunFrame: appId {} is owned again, skipping removal", appId); + continue; + } + if (CSteamApp *pApp = oGetAppByID(g_pController, appId, false)) + { + // Only remove from the library if it's not already uninstalled + pApp->OwnershipFlags = k_EAppOwnershipFlags_None; + if (pApp->AppStateFlags == k_EAppStateUninstalled) { + uninstalledAppIds.push_back(appId); + } + } + + oMarkAppChange(g_pAppChangeSource, appId, EAppChangeFlags::AppInfoOrConfig); + } + if (!uninstalledAppIds.empty()) { std::lock_guard lock(g_removalMutex); - g_removedAppIds.insert(appId); + for (AppId_t appId : uninstalledAppIds) { + g_removedAppIds.insert(appId); + } } } - - oMarkAppChange(g_pAppChangeSource, appId, EAppChangeFlags::AppInfoOrConfig); } } return oCSteamUIAppControllerRunFrame(pController); @@ -110,6 +301,8 @@ namespace namespace Hooks_SteamUI { + static bool g_systemHooksAttached = false; + void Install() { ARM_CAPTURE_U(GetAppByID); @@ -117,26 +310,66 @@ namespace Hooks_SteamUI RESOLVE_U(RepeatedFieldUint32_Add); + bool sysAttached = false; HOOK_BEGIN(); + INSTALL_HOOK_U(LoadModuleWithPath); INSTALL_HOOK_U(FillInAppOverview); INSTALL_HOOK_U(BuildCompleteAppOverviewChange); INSTALL_HOOK_U(CSteamUIAppControllerRunFrame); + + // System module handle redirection for the diversion shadow module. + // These are plain system detours, attached once and idempotently. + if (!g_systemHooksAttached) { + bool ok = true; + if (!OSTPlatform::Detour::Attach(reinterpret_cast(&oGetModuleHandleA), reinterpret_cast(hkGetModuleHandleA))) ok = false; + if (!OSTPlatform::Detour::Attach(reinterpret_cast(&oGetModuleHandleW), reinterpret_cast(hkGetModuleHandleW))) ok = false; + if (!OSTPlatform::Detour::Attach(reinterpret_cast(&oGetModuleHandleExA), reinterpret_cast(hkGetModuleHandleExA))) ok = false; + if (!OSTPlatform::Detour::Attach(reinterpret_cast(&oGetModuleHandleExW), reinterpret_cast(hkGetModuleHandleExW))) ok = false; + if (ok) { + sysAttached = true; + } else { + _ost_detour_transaction_ok_ = false; + } + } + HOOK_END(); + if (sysAttached) { + g_systemHooksAttached = true; + } } void Uninstall() { + bool sysDetached = false; UNHOOK_BEGIN(); + if (g_systemHooksAttached) { + bool ok = true; + if (!OSTPlatform::Detour::Detach(reinterpret_cast(&oGetModuleHandleA), reinterpret_cast(hkGetModuleHandleA))) ok = false; + if (!OSTPlatform::Detour::Detach(reinterpret_cast(&oGetModuleHandleW), reinterpret_cast(hkGetModuleHandleW))) ok = false; + if (!OSTPlatform::Detour::Detach(reinterpret_cast(&oGetModuleHandleExA), reinterpret_cast(hkGetModuleHandleExA))) ok = false; + if (!OSTPlatform::Detour::Detach(reinterpret_cast(&oGetModuleHandleExW), reinterpret_cast(hkGetModuleHandleExW))) ok = false; + if (ok) { + sysDetached = true; + } else { + _ost_detour_transaction_ok_ = false; + } + } + + UNINSTALL_HOOK(LoadModuleWithPath); UNINSTALL_HOOK(FillInAppOverview); UNINSTALL_HOOK(BuildCompleteAppOverviewChange); UNINSTALL_HOOK(CSteamUIAppControllerRunFrame); UNHOOK_END(); + if (sysDetached) { + g_systemHooksAttached = false; + } } void QueueRemoval(AppId_t appId) { std::lock_guard lock(g_removalMutex); g_pendingRemovals.push_back(appId); + g_hasPendingRemovals.store(true, std::memory_order_release); } void CancelRemoval(AppId_t appId) @@ -144,6 +377,9 @@ namespace Hooks_SteamUI std::lock_guard lock(g_removalMutex); std::erase(g_pendingRemovals, appId); g_removedAppIds.erase(appId); + if (g_pendingRemovals.empty()) { + g_hasPendingRemovals.store(false, std::memory_order_release); + } } size_t ActiveDownloadCount() diff --git a/src/OSTPlatform/Windows/SteamCredentialStore.cpp b/src/OSTPlatform/Windows/SteamCredentialStore.cpp index b7f13062..78d1d7ba 100644 --- a/src/OSTPlatform/Windows/SteamCredentialStore.cpp +++ b/src/OSTPlatform/Windows/SteamCredentialStore.cpp @@ -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"; @@ -192,6 +193,48 @@ Status WriteSteamId(uint32_t appId, uint64_t steamId) { reinterpret_cast(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& 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(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); diff --git a/src/OSTPlatform/include/SteamCredentialStore.h b/src/OSTPlatform/include/SteamCredentialStore.h index 67268377..a273de11 100644 --- a/src/OSTPlatform/include/SteamCredentialStore.h +++ b/src/OSTPlatform/include/SteamCredentialStore.h @@ -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& appIds); + } // namespace OSTPlatform::SteamCredentialStore diff --git a/src/Utils/CloudRedirect/CloudRedirectHost.cpp b/src/Utils/CloudRedirect/CloudRedirectHost.cpp index ee12043e..f7048229 100644 --- a/src/Utils/CloudRedirect/CloudRedirectHost.cpp +++ b/src/Utils/CloudRedirect/CloudRedirectHost.cpp @@ -1,6 +1,8 @@ #include "CloudRedirectHost.h" +#include "dllmain.h" #include "OSTPlatform/include/DynamicLibrary.h" +#include "OSTPlatform/include/Encoding.h" #include "Utils/Config/Config.h" #include "Utils/Config/LuaConfig.h" #include "Utils/Logging/Log.h" @@ -63,13 +65,28 @@ namespace { std::filesystem::path ResolveLibraryPath(const std::string& steamRoot, const std::string& configured) { - if (configured.empty()) - return std::filesystem::path(steamRoot) / "cloud_redirect.dll"; - - std::filesystem::path lib(configured); - if (lib.is_absolute()) + std::error_code ec; + const std::string filename = configured.empty() ? "cloud_redirect.dll" : configured; + const std::filesystem::path lib(OSTPlatform::Encoding::Utf8ToWide(filename)); + if (lib.is_absolute()) { return lib; - return std::filesystem::path(steamRoot) / lib; + } + + // Lookup order: next to this DLL, next to opensteamtool.toml (when + // different from the Steam root), then the Steam root itself. + if (DllDir[0] != '\0') { + const auto p = std::filesystem::path(OSTPlatform::Encoding::Utf8ToWide(DllDir)) / lib; + if (std::filesystem::exists(p, ec) && !ec) return p; + } + if (ConfigPath[0] != '\0') { + const auto configParent = std::filesystem::path(OSTPlatform::Encoding::Utf8ToWide(ConfigPath)).parent_path(); + const auto steamRootPath = std::filesystem::path(OSTPlatform::Encoding::Utf8ToWide(steamRoot)); + if (configParent != steamRootPath) { + const auto p = configParent / lib; + if (std::filesystem::exists(p, ec) && !ec) return p; + } + } + return std::filesystem::path(OSTPlatform::Encoding::Utf8ToWide(steamRoot)) / lib; } template @@ -100,15 +117,17 @@ void Initialize(const char* steamInstallPath) { if (g_active.load(std::memory_order_acquire)) return; const std::filesystem::path libPath = ResolveLibraryPath(steamInstallPath, cloud.library); - if (!std::filesystem::exists(libPath)) { - LOG_WARN("CloudRedirect: cloud_redirect.dll not found at {}", libPath.string()); + std::error_code libEc; + const std::string libPathUtf8 = OSTPlatform::Encoding::WideToUtf8(libPath.wstring()); + if (!std::filesystem::exists(libPath, libEc) || libEc) { + LOG_WARN("CloudRedirect: cloud_redirect.dll not found at {}", libPathUtf8); return; } g_module = OSTPlatform::DynamicLibrary::Load(libPath); if (!g_module) { LOG_WARN("CloudRedirect: failed to load {} (err={})", - libPath.string(), OSTPlatform::DynamicLibrary::GetLastErrorCode()); + libPathUtf8, OSTPlatform::DynamicLibrary::GetLastErrorCode()); return; } @@ -139,8 +158,8 @@ void Initialize(const char* steamInstallPath) { } g_active.store(true, std::memory_order_release); - LOG_INFO("CloudRedirect: loaded {} and initialised cloud save redirection", - libPath.string()); + LOG_INFO("CloudRedirect: loaded {} and initialised cloud save redirection (diversion: {:p})", + libPathUtf8, static_cast(client_hModule)); if (g_enableStatsSync) { g_enableStatsSync(true, true); @@ -157,7 +176,7 @@ void Initialize(const char* steamInstallPath) { // Vtable hooks let CR handle Cloud RPCs synchronously (slot4 semantics). if (g_installVtableHooks) { if (g_installVtableHooks()) - LOG_INFO("CloudRedirect: vtable hooks installed"); + LOG_INFO("CloudRedirect: vtable hooks installed (routed to diversion module)"); else LOG_WARN("CloudRedirect: vtable hook install failed, using packet-layer path"); } @@ -215,6 +234,21 @@ void Shutdown() { std::lock_guard lock(g_mutex); if (!g_active.exchange(false)) return; if (g_shutdownFn) g_shutdownFn(); + // Drop every function pointer and the module handle: after shutdown the + // cloud_redirect.dll image may be unloaded and dangling calls must not + // be possible. + g_initCloudSave = nullptr; + g_handleCloudRpc = nullptr; + g_setApps = nullptr; + g_isApp = nullptr; + g_shutdownFn = nullptr; + g_enableStatsSync = nullptr; + g_setAccountId = nullptr; + g_notifyAppRunning = nullptr; + g_notifyStatsStored = nullptr; + g_getAchievements = nullptr; + g_installVtableHooks = nullptr; + g_module = nullptr; LOG_INFO("CloudRedirect: shut down"); } diff --git a/src/Utils/Config/Config.cpp b/src/Utils/Config/Config.cpp index fc84b696..607ef277 100644 --- a/src/Utils/Config/Config.cpp +++ b/src/Utils/Config/Config.cpp @@ -1,6 +1,7 @@ #include "Config.h" #include "Utils/Logging/Log.h" #include "Utils/SteamMetadata/ManifestClient.h" +#include "Utils/Tickets/TicketAuthority.h" #include @@ -19,6 +20,7 @@ namespace { std::vector remoteUrlTemplates; bool statsEnableApi = true; bool updateEnabled = true; + TicketPolicy ticketPolicy; DonateSettings donate; std::vector injectDlls; CloudSettings cloud; @@ -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; @@ -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; } @@ -223,6 +232,34 @@ namespace { snapshot.cloud.library = *val; } + // [tickets] + if (auto tickets = tbl["tickets"].as_table()) { + if (auto v = (*tickets)["prefer_stored"].value()) snapshot.ticketPolicy.preferStored = *v; + if (auto v = (*tickets)["allow_forge"].value()) snapshot.ticketPolicy.allowForge = *v; + if (auto v = (*tickets)["require_appid_match"].value()) snapshot.ticketPolicy.requireAppIdMatch = *v; + if (auto v = (*tickets)["require_account_match"].value()) snapshot.ticketPolicy.requireAccountMatch = *v; + if (auto v = (*tickets)["scan_credential_store"].value()) snapshot.ticketPolicy.scanCredentialStore = *v; + if (auto v = (*tickets)["allow_mixed_identity"].value()) snapshot.ticketPolicy.allowMixedIdentity = *v; + if (auto v = (*tickets)["identity_mode"].value()) { + // Readable policy: "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); + } + if (auto v = (*tickets)["vault"].value()) snapshot.ticketPolicy.vault = *v; + if (auto v = (*tickets)["allow_backend"].value()) snapshot.ticketPolicy.allowBackend = *v; + if (auto v = (*tickets)["report"].value()) snapshot.ticketPolicy.report = *v; + if (auto arr = (*tickets)["sources"].as_array()) { + for (auto& elem : *arr) { + if (auto v = elem.value(); v && *v > 0 && *v <= 0xFFFFFFFFLL) { + snapshot.ticketPolicy.sources.push_back(static_cast(*v)); + } + } + } + } + ApplyManifestProvider(snapshot.manifestProvider); LoadResult result = ApplySnapshotLocked(snapshot); LOG_INFO("Config loaded: manifest.url={} log.level={} lua.paths={} stats.enable_api={} remote.url_templates={}", @@ -299,6 +336,11 @@ namespace { return updateEnabled; } + TicketPolicy GetTicketPolicy() { + std::lock_guard lock(g_mutex); + return ticketPolicy; + } + CloudSettings GetCloudSettings() { std::lock_guard lock(g_mutex); return { diff --git a/src/Utils/Config/Config.h b/src/Utils/Config/Config.h index ad6fbe03..455d427d 100644 --- a/src/Utils/Config/Config.h +++ b/src/Utils/Config/Config.h @@ -26,6 +26,21 @@ namespace Config { bool allGames = false; // false: only Lua-unlocked games }; + // [tickets] - how the ownership-ticket reply is decided (TicketAuthority). + struct TicketPolicy { + bool preferStored = true; // answer with a validated stored ticket when one exists + bool allowForge = true; // fall back to forging from a locally cached blob + bool requireAppIdMatch = true; // stored tickets must embed the requested app id + bool requireAccountMatch = true; // forge bases must belong to the signed-in account + bool scanCredentialStore = true; // last resort: scan per-app credential keys + bool allowMixedIdentity = true; // best-effort: serve the best available bytes even when the + // expected identity is another account (identity_mode="strict" refuses) + bool vault = true; // keep a private copy of signed material under /opensteamtool/vault + bool allowBackend = true; // ask a configured ticket backend as the last resort + bool report = true; // write opensteamtool/tickets.json + std::vector sources; // extra forge bases; empty = built-in defaults + }; + struct CloudSettings { bool enabled = false; std::string library; @@ -46,6 +61,7 @@ namespace Config { CloudSettings GetCloudSettings(); bool GetStatsEnableApi(); bool GetUpdateEnabled(); + TicketPolicy GetTicketPolicy(); // [donate] — contribute manifest request codes for depots this account owns. struct DonateSettings { @@ -83,6 +99,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 diff --git a/src/Utils/Config/ConfigFileWatcher.cpp b/src/Utils/Config/ConfigFileWatcher.cpp index 7058e784..98142d95 100644 --- a/src/Utils/Config/ConfigFileWatcher.cpp +++ b/src/Utils/Config/ConfigFileWatcher.cpp @@ -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 @@ -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(watchDirs.size())); } diff --git a/src/Utils/Config/LuaConfig.cpp b/src/Utils/Config/LuaConfig.cpp index 10945792..858c9dc7 100644 --- a/src/Utils/Config/LuaConfig.cpp +++ b/src/Utils/Config/LuaConfig.cpp @@ -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 @@ -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; } @@ -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; } diff --git a/src/Utils/Config/LuaFileWatcher.cpp b/src/Utils/Config/LuaFileWatcher.cpp index c28c76f5..3e7d7ca6 100644 --- a/src/Utils/Config/LuaFileWatcher.cpp +++ b/src/Utils/Config/LuaFileWatcher.cpp @@ -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 @@ -115,6 +116,9 @@ void ProcessChanges(const std::vector& 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"); diff --git a/src/Utils/Logging/LogModules.def b/src/Utils/Logging/LogModules.def index 64c0de89..1f7842ae 100644 --- a/src/Utils/Logging/LogModules.def +++ b/src/Utils/Logging/LogModules.def @@ -28,3 +28,4 @@ OST_MOD(SteamUI, "steamui") OST_MOD(Inject, "inject") OST_MOD(Pipe, "pipe") OST_MOD(Platform, "platform") +OST_MOD(Ticket, "ticket") diff --git a/src/Utils/SteamMetadata/IPCLoader.cpp b/src/Utils/SteamMetadata/IPCLoader.cpp index c2c646f2..6d93d28c 100644 --- a/src/Utils/SteamMetadata/IPCLoader.cpp +++ b/src/Utils/SteamMetadata/IPCLoader.cpp @@ -189,7 +189,7 @@ bool Load(const std::string& steamclientPath) LOG_INFO("IPCLoader: loaded {} methods across {} interfaces ({})", MethodCount(), InterfaceCount(), - r.fromCache ? "cache fallback" : "remote"); + r.fromCache ? "cache" : "remote"); return true; } diff --git a/src/Utils/SteamMetadata/PatternLoader.cpp b/src/Utils/SteamMetadata/PatternLoader.cpp index e4d39f84..d3ec98bb 100644 --- a/src/Utils/SteamMetadata/PatternLoader.cpp +++ b/src/Utils/SteamMetadata/PatternLoader.cpp @@ -187,7 +187,7 @@ bool Load(OSTPlatform::DynamicLibrary::ModuleHandle module, const std::string& d PatternMap map = ParsePatternString(r.body, &parseErr); if (!map.empty()) { LOG_INFO("PatternLoader: loaded {} patterns for {} ({})", - map.size(), component, r.fromCache ? "cache fallback" : "remote"); + map.size(), component, r.fromCache ? "cache" : "remote"); g_moduleMaps[module] = std::move(map); return true; } diff --git a/src/Utils/SteamMetadata/RemoteToml.cpp b/src/Utils/SteamMetadata/RemoteToml.cpp index 4411e070..95eaf890 100644 --- a/src/Utils/SteamMetadata/RemoteToml.cpp +++ b/src/Utils/SteamMetadata/RemoteToml.cpp @@ -1,4 +1,5 @@ #include "RemoteToml.h" +#include "OSTPlatform/include/Encoding.h" #include "OSTPlatform/include/Http.h" #include "Utils/Config/Config.h" #include "Utils/Logging/Log.h" @@ -6,10 +7,12 @@ #include #include +#include #include #include #include #include +#include namespace RemoteToml { @@ -96,19 +99,35 @@ Result Fetch(const Request& request) request.channel, request.component, out.sha256, hashMs); // 2. Cache path & dir. - fs::path steamRoot = fs::path(request.dllPath).parent_path(); + fs::path steamRoot = fs::path(OSTPlatform::Encoding::Utf8ToWide(request.dllPath)).parent_path(); fs::path cacheDir = steamRoot / "opensteamtool" / request.channel / request.component; fs::path cachePath = cacheDir / (out.sha256 + ".toml"); - const std::string cachePathText = cachePath.string(); - - std::error_code mkdirEc; - fs::create_directories(cacheDir, mkdirEc); - if (mkdirEc) { - LOG_WARN("RemoteToml({}/{}): could not create cache dir {} ({})", - request.channel, request.component, cacheDir.string(), mkdirEc.message()); + const std::string cachePathText = OSTPlatform::Encoding::WideToUtf8(cachePath.wstring()); + + // Check local cache first (pattern & IPC files are immutable per DLL SHA-256). + std::error_code ec; + if (fs::exists(cachePath, ec) && !ec) { + const auto sz = fs::file_size(cachePath, ec); + if (!ec && sz > 0) { + std::ifstream ifs(cachePath, std::ios::binary); + if (ifs) { + std::string buf((std::istreambuf_iterator(ifs)), + std::istreambuf_iterator()); + if (!buf.empty()) { + LOG_INFO("RemoteToml({}/{}): loaded from cache {}", + request.channel, request.component, cachePathText); + out.body = std::move(buf); + out.ok = true; + out.fromCache = true; + return out; + } + } + LOG_WARN("RemoteToml({}/{}): cache file exists but failed to read or empty: {}", + request.channel, request.component, cachePathText); + } } - // 3. Try remote (mirror chain with early-out on 404). + // 3. Cache miss -> Try remote (mirror chain with early-out on 404). const std::vector urlTemplates = BuildUrlTemplates(); OSTPlatform::Http::Result http; std::string lastUrl; @@ -135,49 +154,50 @@ Result Fetch(const Request& request) } } - // 4. Remote OK → write cache, return body. + // 4. Remote OK -> write cache atomically, return body. if (http.ok && http.status == 200 && !http.body.empty()) { - std::ofstream ofs(cachePath, std::ios::binary); + std::error_code mkdirEc; + fs::create_directories(cacheDir, mkdirEc); + if (mkdirEc) { + LOG_WARN("RemoteToml({}/{}): could not create cache dir {} ({})", + request.channel, request.component, + OSTPlatform::Encoding::WideToUtf8(cacheDir.wstring()), mkdirEc.message()); + } + + std::string tempFileName = std::format("{}_{}.tmp", out.sha256, GetCurrentProcessId()); + fs::path tempPath = cacheDir / tempFileName; + std::ofstream ofs(tempPath, std::ios::binary); if (ofs) { ofs.write(http.body.data(), static_cast(http.body.size())); - LOG_INFO("RemoteToml({}/{}): cached to {}", - request.channel, request.component, cachePathText); + ofs.close(); + + if (MoveFileExW(tempPath.c_str(), cachePath.c_str(), MOVEFILE_REPLACE_EXISTING)) { + LOG_INFO("RemoteToml({}/{}): cached to {}", + request.channel, request.component, cachePathText); + } else { + std::error_code renameEc; + fs::rename(tempPath, cachePath, renameEc); + if (!renameEc) { + LOG_INFO("RemoteToml({}/{}): cached to {}", + request.channel, request.component, cachePathText); + } else { + LOG_WARN("RemoteToml({}/{}): atomic cache rename failed: {}", + request.channel, request.component, renameEc.message()); + fs::remove(tempPath, renameEc); + } + } } else { LOG_WARN("RemoteToml({}/{}): could not open {} for writing", - request.channel, request.component, cachePathText); + request.channel, request.component, + OSTPlatform::Encoding::WideToUtf8(tempPath.wstring())); } out.body = std::move(http.body); out.ok = true; return out; } - // 5. Remote failed → fall back to whatever is cached for this exact SHA. - if (fs::exists(cachePath)) { - LOG_WARN("RemoteToml({}/{}): remote failed (last URL {} HTTP {}); " - "falling back to local cache {}", - request.channel, request.component, - lastUrl.empty() ? "" : lastUrl, http.status, cachePathText); - - std::ifstream ifs(cachePath, std::ios::binary); - if (ifs) { - std::string buf((std::istreambuf_iterator(ifs)), - std::istreambuf_iterator()); - if (!buf.empty()) { - out.body = std::move(buf); - out.ok = true; - out.fromCache = true; - return out; - } - LOG_WARN("RemoteToml({}/{}): cache file empty: {}", - request.channel, request.component, cachePathText); - } else { - LOG_WARN("RemoteToml({}/{}): could not open cache file: {}", - request.channel, request.component, cachePathText); - } - } - - // 6. Total failure — caller handles popup / degraded mode. + // 5. Total failure - caller handles popup / degraded mode. LOG_WARN("RemoteToml({}/{}): no source available (last URL: {} HTTP {})", request.channel, request.component, lastUrl.empty() ? "" : lastUrl, http.status); diff --git a/src/Utils/SteamMetadata/RemoteToml.h b/src/Utils/SteamMetadata/RemoteToml.h index e5d56c92..585891cc 100644 --- a/src/Utils/SteamMetadata/RemoteToml.h +++ b/src/Utils/SteamMetadata/RemoteToml.h @@ -16,7 +16,7 @@ namespace RemoteToml { std::string sha256; }; - // Fetch remote TOML first, then fall back to the exact local cache entry. + // Load exact local cache entry first, then fall back to fetching remote TOML. Result Fetch(const Request& request); } // namespace RemoteToml diff --git a/src/Utils/Tickets/AppTicket.cpp b/src/Utils/Tickets/AppTicket.cpp index 4a607938..cc993dc4 100644 --- a/src/Utils/Tickets/AppTicket.cpp +++ b/src/Utils/Tickets/AppTicket.cpp @@ -1,28 +1,31 @@ #include "AppTicket.h" -#include "Hook/Hooks_Decryption.h" + #include "OSTPlatform/include/SteamCredentialStore.h" #include "Utils/Config/LuaConfig.h" #include "Utils/Logging/Log.h" +#include "Utils/Tickets/TicketAuthority.h" namespace AppTicket { - constexpr AppId_t kLocalAppTicketSourceAppId = 7; +namespace { + + constexpr size_t kUsableBlobMinBytes = kAppTicketSignatureSize + 1; // longer than the signature + constexpr size_t kUsableBlobMaxBytes = 0x10000; // 64 KiB sanity bound constexpr size_t kSteamIdTicketMinimumSize = 16; - static uint64_t GetSteamIDFromCredentialStore(AppId_t appId) { - uint64_t steamId = 0; - const auto status = OSTPlatform::SteamCredentialStore::GetSteamId(appId, steamId); - if (status != OSTPlatform::SteamCredentialStore::Status::Ok) { - LOG_TRACE("GetSpoofSteamID for AppId {}: SteamID unavailable in credential store ({})", - appId, OSTPlatform::SteamCredentialStore::ToString(status)); - return 0; - } +} // namespace - LOG_DEBUG("GetSpoofSteamID for AppId {}: SteamID credential -> 0x{:X}({})", appId, steamId, steamId); - return steamId; + bool IsUsableForgeSourceTicket(const std::vector& data) + { + if (data.size() < kUsableBlobMinBytes) return false; + if (data.size() > kUsableBlobMaxBytes) return false; + // The first word is the signature offset the DRM layer reads; a + // placeholder blob has none, so it cannot be served or forged from. + return *reinterpret_cast(data.data()) != 0; } - std::vector GetAppOwnershipTicketFromCredentialStore(AppId_t appId) { - // exclude those appids that are not in addappid + std::vector GetAppOwnershipTicketFromCredentialStore(AppId_t appId) + { + // Exclude app ids that are not managed by Lua. if (!LuaConfig::HasDepot(appId)) { LOG_DEBUG("GetAppOwnershipTicketFromCredentialStore for AppId {}: not in addappid, skip", appId); return {}; @@ -35,63 +38,36 @@ namespace AppTicket { return {}; } - LOG_INFO("Successfully retrieved App Ownership Ticket from credential store, AppId: {}, Ticket Size: {}", appId, ticket.size()); + LOG_DEBUG("Read App Ownership Ticket for AppId {}: {} bytes", appId, ticket.size()); return ticket; } - // Exploit steamdrmp's off-by-four ticket parsing vulnerability: - static std::vector ForgeLocalAppOwnershipTicket(AppId_t appId) { - std::vector source = Hooks_Decryption::GetCacheAppOwnershipTicket(kLocalAppTicketSourceAppId); - if (source.size() <= kAppTicketSignatureSize) { - LOG_DEBUG("ForgeLocalAppOwnershipTicket for AppId {}: no source appticket", appId); + std::vector GetValidatedAppOwnershipTicketFromCredentialStore(AppId_t appId) + { + std::vector ticket = GetAppOwnershipTicketFromCredentialStore(appId); + if (ticket.empty()) return ticket; + + // A stored blob that does not embed this app id (stale write, or a + // ticket meant for another app) must not be served: DRM-wrapped titles + // reject the launch on the mismatch, which surfaces as the "error 54" + // class. Callers fall back to the forge path instead. + if (ticket.size() < kAppTicketAppIdOffset + sizeof(uint32)) { + LOG_WARN("GetValidatedAppOwnershipTicket: AppId {} cached ticket too small ({} bytes), ignoring", + appId, ticket.size()); return {}; } - - const size_t signedSize = source.size() - kAppTicketSignatureSize; - std::vector ticket; - ticket.reserve(source.size() + sizeof(AppId_t)); - ticket.insert(ticket.end(), source.begin(), source.begin() + signedSize); - - const uint8_t* appIdBytes = reinterpret_cast(&appId); - ticket.insert(ticket.end(), appIdBytes, appIdBytes + sizeof(AppId_t)); - ticket.insert(ticket.end(), source.begin() + signedSize, source.end()); - - LOG_INFO("Forged App Ownership Ticket, AppId: {}, SourceAppId: {}, Physical Size: {}, Total Size: {}", - appId, kLocalAppTicketSourceAppId, ticket.size(), source.size()); - return ticket; - } - - bool GetAppOwnershipTicket(AppId_t appId, AppOwnershipTicket& ticket, AppTicketSource source) { - ticket = {}; - - if (source == AppTicketSource::CredentialStoreOnly || source == AppTicketSource::CredentialStoreThenForge) { - ticket.data = GetAppOwnershipTicketFromCredentialStore(appId); - if (!ticket.data.empty() && ticket.data.size() >= sizeof(uint32)) { - ticket.totalSize = static_cast(ticket.data.size()); - ticket.appIdOffset = kAppTicketAppIdOffset; - ticket.steamIdOffset = kAppTicketSteamIdOffset; - ticket.signatureOffset = *reinterpret_cast(ticket.data.data()); - ticket.signatureSize = kAppTicketSignatureSize; - return true; - } + const uint32 embeddedAppId = *reinterpret_cast(ticket.data() + kAppTicketAppIdOffset); + if (embeddedAppId != appId) { + LOG_WARN("GetValidatedAppOwnershipTicket: AppId {} cached ticket embeds AppId {} - stale, ignoring", + appId, embeddedAppId); + return {}; } - - if (source == AppTicketSource::CredentialStoreOnly) return false; - - ticket.data = ForgeLocalAppOwnershipTicket(appId); - if (ticket.data.empty()) return false; - - ticket.totalSize = static_cast(ticket.data.size() - sizeof(AppId_t)); - ticket.appIdOffset = ticket.totalSize - kAppTicketSignatureSize; - ticket.steamIdOffset = kAppTicketSteamIdOffset; - ticket.signatureOffset = ticket.appIdOffset + sizeof(AppId_t); - ticket.signatureSize = kAppTicketSignatureSize; - return true; + return ticket; } - std::vector GetEncryptedTicketFromCredentialStore(AppId_t appId) { - LOG_DEBUG("appid={}", appId); - // exclude those appids that are not in addappid + std::vector GetEncryptedTicketFromCredentialStore(AppId_t appId) + { + // Exclude app ids that are not managed by Lua. if (!LuaConfig::HasDepot(appId)) { LOG_DEBUG("GetEncryptedTicketFromCredentialStore for AppId {}: not in addappid, skip", appId); return {}; @@ -104,12 +80,57 @@ namespace AppTicket { return {}; } - LOG_INFO("Successfully retrieved Encrypted App Ticket from credential store, AppId: {}, Ticket Size: {}", appId, ticket.size()); + LOG_DEBUG("Read Encrypted App Ticket for AppId {}: {} bytes", appId, ticket.size()); + return ticket; + } + + std::vector ForgeFromSource(AppId_t appId, const std::vector& source) + { + // The DRM reads the app id from the word right before the signature, + // which the signature does not cover - so splicing the requested app id + // there keeps the source's signature valid for the new bytes. + if (source.size() <= kAppTicketSignatureSize) return {}; + + const size_t signedSize = source.size() - kAppTicketSignatureSize; + std::vector ticket; + ticket.reserve(source.size() + sizeof(AppId_t)); + ticket.insert(ticket.end(), source.begin(), source.begin() + signedSize); + + const uint8_t* appIdBytes = reinterpret_cast(&appId); + ticket.insert(ticket.end(), appIdBytes, appIdBytes + sizeof(AppId_t)); + ticket.insert(ticket.end(), source.begin() + signedSize, source.end()); return ticket; } - bool WriteAppOwnershipTicket(AppId_t appId, const std::vector& data) { - // we can't execlude appids here + uint64_t GetSpoofSteamID(AppId_t appId) + { + // Exclude app ids that are not managed by Lua. + if (!LuaConfig::HasDepot(appId)) { + LOG_DEBUG("GetSpoofSteamID for AppId {}: not in addappid, skip spoofing", appId); + return 0; + } + + // The identity to report is derived by the ticket authority from the + // same material that decides which bytes are served, so the two can + // never disagree. 0 means "keep the real signed-in account". + const uint64_t identity = TicketAuthority::IdentityToReport(appId); + if (identity == 0) { + LOG_DEBUG("GetSpoofSteamID for AppId {}: no foreign identity to report", appId); + return 0; + } + LOG_DEBUG("GetSpoofSteamID for AppId {}: -> 0x{:X}({})", appId, identity, identity); + return identity; + } + + uint64_t ExtractSteamIdFromTicketBytes(const std::vector& ticket) + { + // Layout: ticket bytes start with [uint32 Size][uint32 Version][uint64 SteamID][...]. + if (ticket.size() < kSteamIdTicketMinimumSize) return 0; + return reinterpret_cast(ticket.data())[1]; + } + + bool WriteAppOwnershipTicket(AppId_t appId, const std::vector& data) + { const auto status = OSTPlatform::SteamCredentialStore::WriteAppTicket(appId, data); if (status != OSTPlatform::SteamCredentialStore::Status::Ok) { LOG_ERROR("Failed to write AppTicket for AppId {} to credential store: {}", @@ -121,8 +142,8 @@ namespace AppTicket { return true; } - bool WriteEncryptedTicket(AppId_t appId, const std::vector& data) { - // we can't execlude appids here + bool WriteEncryptedTicket(AppId_t appId, const std::vector& data) + { const auto status = OSTPlatform::SteamCredentialStore::WriteETicket(appId, data); if (status != OSTPlatform::SteamCredentialStore::Status::Ok) { LOG_ERROR("Failed to write ETicket for AppId {} to credential store: {}", @@ -134,7 +155,8 @@ namespace AppTicket { return true; } - bool WriteSteamID(AppId_t appId, uint64_t steamId) { + bool WriteSteamID(AppId_t appId, uint64_t steamId) + { const auto status = OSTPlatform::SteamCredentialStore::WriteSteamId(appId, steamId); if (status != OSTPlatform::SteamCredentialStore::Status::Ok) { LOG_ERROR("Failed to write SteamID for AppId {} to credential store: {}", @@ -146,30 +168,4 @@ namespace AppTicket { return true; } - uint64_t ExtractSteamIdFromTicketBytes(const std::vector& ticket) { - // Layout: ticket bytes start with [uint32 Size][uint32 Version][uint64 SteamID][...]. - if (ticket.size() < kSteamIdTicketMinimumSize) return 0; - return reinterpret_cast(ticket.data())[1]; - } - - uint64_t GetSpoofSteamID(AppId_t appId) { - // exclude those appids that are not in addappid - if (!LuaConfig::HasDepot(appId)) { - LOG_DEBUG("GetSpoofSteamID for AppId {}: not in addappid, skip spoofing", appId); - return 0; - } - const uint64_t credentialSteamID = GetSteamIDFromCredentialStore(appId); - if (credentialSteamID != 0) { - return credentialSteamID; - } - - // The SteamID baked into the cached AppOwnershipTicket is the same - // one Steam itself uses for this app — pull it straight out of the - // ticket so spoofed responses match what the DRM layer expects. - const uint64_t steamID = ExtractSteamIdFromTicketBytes(GetAppOwnershipTicketFromCredentialStore(appId)); - if (steamID) { - LOG_DEBUG("GetSpoofSteamID for AppId {}: -> 0x{:X}({})", appId, steamID, steamID); - } - return steamID; - } -} +} // namespace AppTicket diff --git a/src/Utils/Tickets/AppTicket.h b/src/Utils/Tickets/AppTicket.h index 142ca682..87e15be6 100644 --- a/src/Utils/Tickets/AppTicket.h +++ b/src/Utils/Tickets/AppTicket.h @@ -10,46 +10,41 @@ namespace AppTicket { inline constexpr uint32 kAppTicketAppIdOffset = 16; inline constexpr uint32 kAppTicketSignatureSize = 128; - enum class AppTicketSource { - CredentialStoreOnly, - ForgeOnly, - CredentialStoreThenForge, - }; - - struct AppOwnershipTicket { - std::vector data; - uint32 totalSize = 0; - uint32 appIdOffset = kAppTicketAppIdOffset; - uint32 steamIdOffset = kAppTicketSteamIdOffset; - uint32 signatureOffset = 0; - uint32 signatureSize = kAppTicketSignatureSize; - }; - - // Reads the app ownership ticket cached by Steam's local credential store. - // Returns an empty vector when no ticket is available. + // Raw read of the credential-store ticket for appId (gated by the Lua + // addappid list). Returns an empty vector when no credential exists. std::vector GetAppOwnershipTicketFromCredentialStore(AppId_t appId); - bool GetAppOwnershipTicket(AppId_t appId, AppOwnershipTicket& ticket, AppTicketSource source); + // Same read, additionally requiring the blob to embed appId at + // kAppTicketAppIdOffset. Stale entries written for another app are ignored. + std::vector GetValidatedAppOwnershipTicketFromCredentialStore(AppId_t appId); - // Reads the encrypted app ticket cached by Steam's local credential store. - // Returns an empty vector when no ticket is available. + // Encrypted app ticket (strict Denuvo) from the credential store. std::vector GetEncryptedTicketFromCredentialStore(AppId_t appId); - //Get spoof steamID From the cached AppOwnershipTicket for the given AppId. + // True when a raw blob is a plausible signed ticket: longer than the + // 128-byte signature it carries, within a sane size, with a non-zero + // signature-offset word. Placeholder blobs fail this. + bool IsUsableForgeSourceTicket(const std::vector& data); + + // Builds a ticket for appId from any signed source blob by splicing appId + // ahead of the trailing signature - the spot SteamDRMP's parser reads as + // the app id, which the signature does not cover. Returns empty when the + // source is too small to carry a signature. + std::vector ForgeFromSource(AppId_t appId, const std::vector& source); + + // SteamID to report for appId: the stored credential identity while the + // ticket authority answers from the store, otherwise 0 so the real account + // identity stays consistent with the bytes actually served. uint64_t GetSpoofSteamID(AppId_t appId); - // Parses the SteamID baked into app-ownership-ticket bytes (offset - // kAppTicketSteamIdOffset). Returns 0 if the ticket is too short to - // contain one. Lets callers identify which account a ticket belongs to - // without duplicating the layout knowledge. + // Parses the SteamID baked into ticket bytes (kAppTicketSteamIdOffset). + // Returns 0 when the blob is too short to contain one. uint64_t ExtractSteamIdFromTicketBytes(const std::vector& ticket); - // Write AppTicket binary data to Steam's local credential store. + // Write AppTicket / ETicket / SteamID binary data to Steam's local + // credential store. bool WriteAppOwnershipTicket(AppId_t appId, const std::vector& data); - - // Write ETicket binary data to Steam's local credential store. bool WriteEncryptedTicket(AppId_t appId, const std::vector& data); - - // Write authorized SteamID to Steam's local credential store. bool WriteSteamID(AppId_t appId, uint64_t steamId); -} + +} // namespace AppTicket diff --git a/src/Utils/Tickets/EticketClient.cpp b/src/Utils/Tickets/EticketClient.cpp index 72c76c49..efdc2d7d 100644 --- a/src/Utils/Tickets/EticketClient.cpp +++ b/src/Utils/Tickets/EticketClient.cpp @@ -240,4 +240,14 @@ std::optional> FetchOwnershipTicket(AppId_t appId, std::spa return t.ownership; } +std::optional FetchTickets(AppId_t appId, std::span nonce, uint64_t existingSteamId) { + CachedTickets t; + if (!EnsureFetched(appId, nonce, existingSteamId, t) || t.ownership.empty()) return std::nullopt; + FetchedTickets out; + out.ownership = std::move(t.ownership); + out.eticket = std::move(t.eticket); + out.steamId = t.steamId; + return out; +} + } // namespace EticketClient diff --git a/src/Utils/Tickets/EticketClient.h b/src/Utils/Tickets/EticketClient.h index 083eb14a..d6042920 100644 --- a/src/Utils/Tickets/EticketClient.h +++ b/src/Utils/Tickets/EticketClient.h @@ -47,4 +47,17 @@ namespace EticketClient { // nonce and existingSteamId are only used on the first fetch for an app. std::optional> FetchOwnershipTicket(AppId_t appId, std::span nonce, uint64_t existingSteamId = 0); + // Both halves of one mint, plus the pool account they belong to. Lets a + // caller serve the ownership ticket and report the matching SteamID from + // the same decision. Returns nullopt on any failure (disabled, backend + // down, bad response) or when the response carries no ownership ticket. + struct FetchedTickets + { + std::vector ownership; + std::vector eticket; + uint64_t steamId = 0; + }; + std::optional FetchTickets(AppId_t appId, std::span nonce, + uint64_t existingSteamId = 0); + } // namespace EticketClient diff --git a/src/Utils/Tickets/TicketAuthority.cpp b/src/Utils/Tickets/TicketAuthority.cpp new file mode 100644 index 00000000..1c59322d --- /dev/null +++ b/src/Utils/Tickets/TicketAuthority.cpp @@ -0,0 +1,912 @@ +#include "TicketAuthority.h" + +#include "Hook/Hooks_Decryption.h" +#include "OSTPlatform/include/SteamCredentialStore.h" +#include "Steam/Enums.h" +#include "Steam/Structs.h" +#include "Utils/Config/Config.h" +#include "Utils/Config/LuaConfig.h" +#include "Utils/Logging/Log.h" +#include "Utils/Tickets/AppTicket.h" +#include "Utils/Tickets/EticketClient.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace TicketAuthority { +namespace { + + // The Steam client's own app id. A logged-in account always keeps a cached + // ticket for it, which makes it the last-resort base - never the first one. + constexpr AppId_t kClientAppId = 7; + + enum class BaseKind : uint8 + { + ConfigStore, // Local ConfigStore key apptickets\ + CredentialStore, // HKCU\Software\Valve\Steam\Apps\\AppTicket + Vault, // our own copy of signed material that worked before + }; + + struct BaseRef + { + BaseKind kind = BaseKind::ConfigStore; + AppId_t appId = 0; + }; + + // Session notes about ticket material handed over by the config/redeem + // paths. Lua re-runs on every Steam start, so in-memory is enough - and it + // lets the authority warn about incomplete imports before any launch. + struct ImportNote + { + bool hasAppTicket = false; + uint64 appTicketSteamId = 0; + uint32 appTicketEmbeddedApp = 0; // 0 when the blob is too short to say + bool hasETicket = false; + }; + + struct Entry + { + Decision decision; + Identity identity; + bool hasETicket = false; // pool eticket present for this app + }; + + std::mutex g_mutex; + std::unordered_map g_entries; + std::unordered_map g_imports; + std::optional g_lastGoodBase; // base that already worked this session + + // -- helpers -------------------------------------------------------------- + + // -- private ticket vault ------------------------------------------------- + // Our own copy of signed material that once worked, under + // /opensteamtool/vault. Steam clearing its caches, an update wiping + // ConfigStore, or a reinstall cannot empty the machine while this exists - + // it is what makes the "no material left" case recoverable, and it is a + // mechanism no other tool in this family has. + + std::filesystem::path VaultDir() + { + const std::string dir = Config::GetLogDir(); + if (dir.empty()) return {}; + return std::filesystem::path(dir) / "vault"; + } + + bool VaultLoad(AppId_t appId, std::vector& out) + { + out.clear(); + const std::filesystem::path dir = VaultDir(); + if (dir.empty()) return false; + std::ifstream in(dir / (std::to_string(appId) + ".bin"), std::ios::binary); + if (!in) return false; + out.assign(std::istreambuf_iterator(in), std::istreambuf_iterator()); + return !out.empty(); + } + + void VaultSave(AppId_t appId, const std::vector& blob, uint64 owner, const char* kind) + { + if (appId == 0 || !AppTicket::IsUsableForgeSourceTicket(blob)) return; + const std::filesystem::path dir = VaultDir(); + if (dir.empty()) return; + + std::error_code ec; + std::filesystem::create_directories(dir, ec); + + std::ofstream out(dir / (std::to_string(appId) + ".bin"), std::ios::binary | std::ios::trunc); + if (!out) return; + out.write(reinterpret_cast(blob.data()), static_cast(blob.size())); + out.close(); + + // Sidecar for humans and support: which account the blob belongs to and + // where it came from (the blob itself still carries the SteamID). + std::ofstream meta(dir / (std::to_string(appId) + ".txt"), std::ios::trunc); + if (meta) meta << "owner=" << owner << "\nkind=" << (kind ? kind : "") << "\n"; + } + + void VaultList(std::vector& out) + { + const std::filesystem::path dir = VaultDir(); + if (dir.empty()) return; + + std::error_code ec; + for (std::filesystem::directory_iterator it(dir, ec), end; it != end && !ec; it.increment(ec)) { + if (!it->is_regular_file(ec)) continue; + const std::string name = it->path().filename().string(); + if (name.size() < 5 || name.compare(name.size() - 4, 4, ".bin") != 0) continue; + + AppId_t appId = 0; + bool valid = true; + for (size_t i = 0; i + 4 < name.size(); ++i) { + const char c = name[i]; + if (!std::isdigit(static_cast(c))) { valid = false; break; } + appId = appId * 10 + static_cast(c - '0'); + } + if (valid && appId != 0) out.push_back(appId); + } + } + + // -- helpers -------------------------------------------------------------- + + bool ReadBase(BaseKind kind, AppId_t appId, std::vector& out) + { + if (kind == BaseKind::ConfigStore) { + out = Hooks_Decryption::GetCacheAppOwnershipTicket(appId); + } else if (kind == BaseKind::Vault) { + VaultLoad(appId, out); + } else { + out.clear(); + if (OSTPlatform::SteamCredentialStore::GetAppTicket(appId, out) + != OSTPlatform::SteamCredentialStore::Status::Ok) { + out.clear(); + } + } + return !out.empty(); + } + + uint32 EmbeddedAppId(const std::vector& blob) + { + if (blob.size() < AppTicket::kAppTicketAppIdOffset + sizeof(uint32)) return 0; + return *reinterpret_cast(blob.data() + AppTicket::kAppTicketAppIdOffset); + } + + // VDF strings escape backslashes; undo that for path values. + std::string UnescapeVdfPath(const std::string& in) + { + std::string out; + out.reserve(in.size()); + for (size_t i = 0; i < in.size(); ++i) { + if (in[i] == '\\' && i + 1 < in.size()) out += in[++i]; + else out += in[i]; + } + return out; + } + + // App ids this machine actually has installed: steamapps/appmanifest_.acf + // for the Steam root and every library folder listed in libraryfolders.vdf. + // These are the user's own (normally owned) apps, so their cached tickets + // are the most natural forge bases - which is why they are discovered here + // instead of a fixed app id list being compiled into the DLL. + void CollectInstalledAppIds(const std::filesystem::path& steamRoot, std::vector& out) + { + namespace fs = std::filesystem; + std::error_code ec; + + std::vector libraries; + auto addLibrary = [&](const fs::path& root) { + if (root.empty()) return; + for (const fs::path& existing : libraries) if (existing == root) return; + libraries.push_back(root); + }; + addLibrary(steamRoot); + + if (std::ifstream vdf(steamRoot / "steamapps" / "libraryfolders.vdf"); vdf) { + std::string line; + while (std::getline(vdf, line)) { + const size_t key = line.find("\"path\""); + if (key == std::string::npos) continue; + const size_t first = line.find('"', key + 6); + if (first == std::string::npos) continue; + const size_t second = line.find('"', first + 1); + if (second == std::string::npos || second <= first + 1) continue; + addLibrary(UnescapeVdfPath(line.substr(first + 1, second - first - 1))); + } + } + + constexpr const char* kManifestPrefix = "appmanifest_"; + constexpr size_t kManifestPrefixLen = 12; // strlen(kManifestPrefix) + for (const fs::path& library : libraries) { + const fs::path steamapps = library / "steamapps"; + if (!fs::exists(steamapps, ec)) continue; + + for (fs::directory_iterator it(steamapps, ec), end; it != end && !ec; it.increment(ec)) { + if (!it->is_regular_file(ec)) continue; + const std::string name = it->path().filename().string(); + if (name.rfind(kManifestPrefix, 0) != 0) continue; + if (it->path().extension() != ".acf") continue; + + const std::string digits = + name.substr(kManifestPrefixLen, name.size() - kManifestPrefixLen - 4); + if (digits.empty()) continue; + + AppId_t appId = 0; + bool valid = true; + for (char c : digits) { + if (!std::isdigit(static_cast(c))) { valid = false; break; } + appId = appId * 10 + static_cast(c - '0'); + } + if (valid && appId != 0) out.push_back(appId); + } + } + } + + // SteamID64 of the signed-in account, or 0 when it cannot be determined. + uint64 ActiveAccountSteamId() + { + uint32 accountId = 0; + std::wstring universeName; + if (OSTPlatform::SteamCredentialStore::GetActiveUser(accountId, universeName) + != OSTPlatform::SteamCredentialStore::Status::Ok || accountId == 0) { + return 0; + } + + EUniverse universe = k_EUniverseInvalid; + if (universeName == L"Public") universe = k_EUniversePublic; + else if (universeName == L"Beta") universe = k_EUniverseBeta; + else if (universeName == L"Internal") universe = k_EUniverseInternal; + else if (universeName == L"Dev") universe = k_EUniverseDev; + if (universe == k_EUniverseInvalid) return 0; + + CSteamID steamId; + steamId.Set(accountId, universe, k_EAccountTypeIndividual); + return steamId.ConvertToUint64(); + } + + bool StoreHasETicket(AppId_t appId) + { + return !AppTicket::GetEncryptedTicketFromCredentialStore(appId).empty(); + } + + // The account the DRM will check everything against, derived from local + // material in priority order: validated stored ticket -> stored SteamID + // credential -> this session's import notes -> ETicket (account unknown). + Identity DeriveIdentityLocked(AppId_t appId) + { + Identity identity; + const uint64 local = ActiveAccountSteamId(); + + std::vector stored = AppTicket::GetAppOwnershipTicketFromCredentialStore(appId); + if (!stored.empty() && AppTicket::IsUsableForgeSourceTicket(stored)) { + const uint64 id = AppTicket::ExtractSteamIdFromTicketBytes(stored); + if (id != 0) { + identity.known = true; + identity.steamId = id; + identity.foreign = (local == 0 || id != local); + identity.source = "stored_appticket"; + return identity; + } + } + + uint64 storedSteamId = 0; + if (OSTPlatform::SteamCredentialStore::GetSteamId(appId, storedSteamId) + == OSTPlatform::SteamCredentialStore::Status::Ok && storedSteamId != 0) { + identity.known = true; + identity.steamId = storedSteamId; + identity.foreign = (local == 0 || storedSteamId != local); + identity.source = "stored_steamid"; + return identity; + } + + if (auto it = g_imports.find(appId); it != g_imports.end()) { + const ImportNote& note = it->second; + if (note.hasAppTicket && note.appTicketSteamId != 0) { + identity.known = true; + identity.steamId = note.appTicketSteamId; + identity.foreign = (local == 0 || note.appTicketSteamId != local); + identity.source = "imported_appticket"; + return identity; + } + if (note.hasETicket) { + identity.foreign = true; + identity.known = false; + identity.source = "imported_eticket"; + return identity; + } + } + + if (StoreHasETicket(appId)) { + identity.foreign = true; + identity.known = false; + identity.source = "stored_eticket"; + return identity; + } + + identity.known = (local != 0); + identity.steamId = local; + identity.source = "signed_in_account"; + return identity; + } + + // -- report helpers ------------------------------------------------------- + + std::string JsonEscape(const std::string& in) + { + std::string out; + out.reserve(in.size() + 8); + for (char c : in) { + switch (c) { + case '"': out += "\\\""; break; + case '\\': out += "\\\\"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + default: + if (static_cast(c) < 0x20) { + char buf[8]; + std::snprintf(buf, sizeof(buf), "\\u%04x", static_cast(c)); + out += buf; + } else { + out += c; + } + } + } + return out; + } + + std::string TimestampUtc() + { + const std::time_t now = std::time(nullptr); + std::tm tm{}; + gmtime_s(&tm, &now); + char buf[32]; + std::strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%SZ", &tm); + return buf; + } + + // User-provided base hints ([tickets].sources). Empty in normal setups: + // forge bases are discovered, not configured. + void AppendSourceList(std::ostringstream& os, const Config::TicketPolicy& policy) + { + os << '['; + for (size_t i = 0; i < policy.sources.size(); ++i) { + if (i) os << ','; + os << policy.sources[i]; + } + os << ']'; + } + + std::string BuildReportLocked(const Config::TicketPolicy& policy) + { + std::ostringstream os; + os << "{\n"; + os << " \"generated\": \"" << TimestampUtc() << "\",\n"; + + os << " \"policy\": {\n"; + os << " \"prefer_stored\": " << (policy.preferStored ? "true" : "false") << ",\n"; + os << " \"allow_forge\": " << (policy.allowForge ? "true" : "false") << ",\n"; + os << " \"require_appid_match\": " << (policy.requireAppIdMatch ? "true" : "false") << ",\n"; + os << " \"require_account_match\": " << (policy.requireAccountMatch ? "true" : "false") << ",\n"; + os << " \"scan_credential_store\": " << (policy.scanCredentialStore ? "true" : "false") << ",\n"; + os << " \"identity_mode\": \"" << (policy.allowMixedIdentity ? "best_effort" : "strict") << "\",\n"; + os << " \"vault\": " << (policy.vault ? "true" : "false") << ",\n"; + os << " \"allow_backend\": " << (policy.allowBackend ? "true" : "false") << ",\n"; + os << " \"sources\": "; + AppendSourceList(os, policy); + os << ",\n \"base_discovery\": true\n },\n"; + + uint32 evaluated = 0, stored = 0, forged = 0, unserved = 0; + for (const auto& [appId, entry] : g_entries) { + (void)appId; + ++evaluated; + if (!entry.decision.servable) ++unserved; + else if (entry.decision.provenance == Provenance::Stored) ++stored; + else ++forged; + } + os << " \"summary\": { \"evaluated\": " << evaluated + << ", \"served_stored\": " << stored + << ", \"served_forge\": " << forged + << ", \"unserved\": " << unserved << " },\n"; + + os << " \"apps\": [\n"; + size_t index = 0; + for (const auto& [appId, entry] : g_entries) { + const Decision& d = entry.decision; + os << " { \"appid\": " << appId + << ", \"servable\": " << (d.servable ? "true" : "false") + << ", \"provenance\": \"" << ToString(d.provenance) << "\"" + << ", \"identity_pool\": " << (entry.identity.foreign ? "true" : "false") + << ", \"identity_known\": " << (entry.identity.known ? "true" : "false") + << ", \"identity_steamid\": " << entry.identity.steamId + << ", \"identity_source\": \"" << JsonEscape(entry.identity.source) << "\"" + << ", \"mixed_identity\": " << (d.mixedIdentity ? "true" : "false") + << ", \"source_app\": " << d.sourceApp + << ", \"owner_steamid\": " << d.ownerSteamId + << ", \"has_eticket\": " << (entry.hasETicket ? "true" : "false") + << ", \"bytes\": " << d.bytes.size() + << ", \"reason\": \"" << JsonEscape(d.reason) << "\" }"; + if (++index < g_entries.size()) os << ','; + os << "\n"; + } + os << " ]\n}\n"; + return os.str(); + } + + void FlushReport(const std::string& json) + { + const std::string dir = Config::GetLogDir(); + if (dir.empty()) return; + + std::error_code ec; + std::filesystem::create_directories(dir, ec); + std::ofstream out(std::filesystem::path(dir) / "tickets.json", std::ios::trunc); + if (!out) return; + out << json; + } + + // -- the evaluation itself ------------------------------------------------ + + Entry EvaluateLocked(AppId_t appId, const Config::TicketPolicy& policy) + { + Entry entry; + Decision& d = entry.decision; + d.appIdOffset = AppTicket::kAppTicketAppIdOffset; + d.steamIdOffset = AppTicket::kAppTicketSteamIdOffset; + d.signatureSize = AppTicket::kAppTicketSignatureSize; + + entry.identity = DeriveIdentityLocked(appId); + entry.hasETicket = StoreHasETicket(appId) + || (g_imports.count(appId) && g_imports[appId].hasETicket); + d.identity = entry.identity; + + if (!LuaConfig::HasDepot(appId)) { + d.reason = "app id is not managed by Lua (addappid)"; + return entry; + } + + // 1) Stored bytes. This is the only source that carries an account's + // identity directly, so it is preferred whenever it validates. Our + // own vault is consulted too - it survives Steam cache clears. + std::vector storedRaw; // validation failures kept for a last-resort rung + if (policy.preferStored) { + bool fromVault = false; + std::vector stored = AppTicket::GetAppOwnershipTicketFromCredentialStore(appId); + if (stored.empty()) { + fromVault = VaultLoad(appId, stored); + } + if (!stored.empty()) { + const uint32 embedded = EmbeddedAppId(stored); + if (!AppTicket::IsUsableForgeSourceTicket(stored)) { + LOG_TICKET_WARN("AppId {}: stored ticket is malformed ({} bytes); it will only be served if nothing else exists", + appId, stored.size()); + storedRaw = std::move(stored); + } else if (policy.requireAppIdMatch && embedded != appId) { + LOG_TICKET_WARN("AppId {}: stored ticket embeds AppId {} (stale/foreign); it will only be served if nothing else exists", + appId, embedded); + storedRaw = std::move(stored); + } else { + d.servable = true; + d.provenance = fromVault ? Provenance::StoredVault : Provenance::Stored; + d.ownerSteamId = AppTicket::ExtractSteamIdFromTicketBytes(stored); + d.totalSize = static_cast(stored.size()); + d.signatureOffset = *reinterpret_cast(stored.data()); + d.bytes = std::move(stored); + d.reason = fromVault ? "validated stored ticket (private vault)" + : "validated stored ticket"; + if (policy.vault) VaultSave(appId, d.bytes, d.ownerSteamId, "stored"); + return entry; + } + } + } + + if (!policy.allowForge) { + d.reason = "no validated stored ticket and forging is disabled ([tickets].allow_forge)"; + return entry; + } + + // Identity policy: "strict" refuses answers whose bytes describe a + // different account than the expected identity. "best-effort" (default) + // prefers matching bases but still serves the best available bytes, + // recording the mixing - an empty answer guarantees the launch failure + // while a mixed one may still pass, so falling back can only help. + const bool strict = policy.requireAccountMatch && !policy.allowMixedIdentity; + const uint64 localSteamId = strict ? ActiveAccountSteamId() : 0; + + if (strict && entry.identity.foreign && !entry.identity.known) { + d.reason = "pool ticket material present but its account is unknown; import " + "setAppTicket from the same source (extract_tickets on the owning " + "account) or re-redeem (or set [tickets] identity_mode = \"best_effort\")"; + return entry; + } + + auto baseMatchesIdentity = [&](uint64 baseSteamId) -> bool { + if (entry.identity.foreign) { + return entry.identity.steamId != 0 && baseSteamId == entry.identity.steamId; + } + // Local identity: an unknown-account base is acceptable as before, + // a known one must be the signed-in account. + return baseSteamId == 0 || localSteamId == 0 || baseSteamId == localSteamId; + }; + + auto tryBase = [&](BaseKind kind, AppId_t baseApp, Provenance fromProvider, bool requireMatch) -> bool { + if (baseApp == 0 || baseApp == appId) return false; // a blob cannot forge itself + + std::vector base; + if (!ReadBase(kind, baseApp, base)) return false; + if (!AppTicket::IsUsableForgeSourceTicket(base)) return false; + + const uint64 baseSteamId = AppTicket::ExtractSteamIdFromTicketBytes(base); + if (requireMatch && !baseMatchesIdentity(baseSteamId)) { + LOG_TICKET_DEBUG("AppId {}: base AppId {} (account {}) does not match the expected identity, skipping", + appId, baseApp, baseSteamId); + return false; + } + + std::vector forged = AppTicket::ForgeFromSource(appId, base); + if (forged.empty()) return false; + + d.servable = true; + d.provenance = fromProvider; + d.sourceApp = baseApp; + d.ownerSteamId = baseSteamId; + d.bytes = std::move(forged); + d.totalSize = static_cast(d.bytes.size() - sizeof(AppId_t)); + d.appIdOffset = d.totalSize - AppTicket::kAppTicketSignatureSize; + d.steamIdOffset = AppTicket::kAppTicketSteamIdOffset; + d.signatureOffset = d.appIdOffset + sizeof(AppId_t); + d.signatureSize = AppTicket::kAppTicketSignatureSize; + + const bool matchesExpected = entry.identity.known && entry.identity.steamId != 0 && + baseSteamId == entry.identity.steamId; + d.mixedIdentity = entry.identity.foreign && !matchesExpected; + + if (policy.vault) VaultSave(baseApp, base, baseSteamId, "base"); + return true; + }; + + // 2a) The base that already worked earlier in this session. + if (g_lastGoodBase.has_value() + && tryBase(g_lastGoodBase->kind, g_lastGoodBase->appId, Provenance::ForgeFromCache, false)) { + d.reason = "forged from base that already worked this session"; + return entry; + } + + // 2b) Base providers, in preference order. There is deliberately no + // fixed app id list in the DLL - candidates are discovered: + // vault -> hints -> Lua-managed -> installed apps -> per-app + // credentials (both storage kinds) -> the Steam client's own + // ticket (last local). + struct Candidate { + BaseKind kind; + AppId_t appId; + int rank; + Provenance provider; + const char* providerName; + }; + std::vector candidates; + auto addCandidate = [&](BaseKind kind, AppId_t app, int rank, + Provenance provider, const char* providerName) { + if (app == 0 || app == appId) return; + for (const Candidate& existing : candidates) + if (existing.kind == kind && existing.appId == app) return; + candidates.push_back(Candidate{ kind, app, rank, provider, providerName }); + }; + + { + std::vector vaultIds; + VaultList(vaultIds); + for (AppId_t app : vaultIds) + addCandidate(BaseKind::Vault, app, 0, Provenance::ForgeFromVault, "vault"); + } + + for (AppId_t hint : policy.sources) + addCandidate(BaseKind::ConfigStore, hint, 1, Provenance::ForgeFromHint, "hint"); + + { + // App ids this Lua setup manages: the games actually played here, + // so their cached tickets are strong candidates. Bounded so a huge + // depot list cannot stall the decision. + constexpr size_t kMaxLuaCandidates = 128; + const std::vector managed = LuaConfig::GetAllDepotIds(); + size_t added = 0; + for (AppId_t app : managed) { + if (added >= kMaxLuaCandidates) break; + addCandidate(BaseKind::ConfigStore, app, 2, Provenance::ForgeFromLua, "lua"); + ++added; + } + } + + const std::string logDir = Config::GetLogDir(); + if (!logDir.empty()) { + std::vector installed; + CollectInstalledAppIds(std::filesystem::path(logDir).parent_path(), installed); + for (AppId_t app : installed) + addCandidate(BaseKind::ConfigStore, app, 3, Provenance::ForgeFromInstalled, "installed"); + } + + if (policy.scanCredentialStore) { + std::vector credentials; + if (OSTPlatform::SteamCredentialStore::EnumerateAppIds(credentials) + == OSTPlatform::SteamCredentialStore::Status::Ok) { + for (uint32_t credential : credentials) { + const AppId_t app = static_cast(credential); + // Union probing: an id discovered here is tried in BOTH + // storage locations, so a ConfigStore-only blob (Steam's + // own cache) is found even without a registry credential. + addCandidate(BaseKind::CredentialStore, app, 4, Provenance::ForgeFromRegistry, "credential"); + addCandidate(BaseKind::ConfigStore, app, 4, Provenance::ForgeFromRegistry, "credential"); + } + } + } + + addCandidate(BaseKind::ConfigStore, kClientAppId, 5, Provenance::ForgeFromClient, "client"); + addCandidate(BaseKind::CredentialStore, kClientAppId, 5, Provenance::ForgeFromClient, "client"); + addCandidate(BaseKind::Vault, kClientAppId, 5, Provenance::ForgeFromClient, "client"); + + std::stable_sort(candidates.begin(), candidates.end(), + [](const Candidate& a, const Candidate& b) { + if (a.rank != b.rank) return a.rank < b.rank; + return a.appId < b.appId; // deterministic tie-break + }); + + auto runCandidates = [&](bool requireMatch) -> bool { + for (const Candidate& candidate : candidates) { + if (tryBase(candidate.kind, candidate.appId, candidate.provider, requireMatch)) { + g_lastGoodBase = BaseRef{ candidate.kind, candidate.appId }; + d.reason = std::string("forged from ") + candidate.providerName + " base"; + return true; + } + } + return false; + }; + + bool served = false; + const bool matchingFirst = entry.identity.known && entry.identity.steamId != 0; + if (matchingFirst) { + served = runCandidates(true); + } + if (!served && !strict) { + // Best-effort pass: accept any usable base and record the mixing. + served = runCandidates(false); + } + if (!served && strict && !matchingFirst) { + // Strict mode with an unidentifiable expected account (e.g. the + // active-user key is unavailable): mirror the historical behavior. + served = runCandidates(true); + } + if (served) return entry; + + // 2c) Ticket backend (only when this build/user configured one). The + // mint returns the ownership ticket AND the pool account it was + // minted for, so the served bytes and the reported identity stay on + // that one account. This is the last resort: it is the only path + // that can answer when this machine holds no signed material at all. + if (policy.allowBackend) { + const uint64 existingSteamId = entry.identity.known ? entry.identity.steamId : 0; + if (auto fetched = EticketClient::FetchTickets(appId, {}, existingSteamId); + fetched && AppTicket::IsUsableForgeSourceTicket(fetched->ownership)) { + d.servable = true; + d.provenance = Provenance::FetchedFromBackend; + d.ownerSteamId = fetched->steamId; + d.bytes = std::move(fetched->ownership); + d.totalSize = static_cast(d.bytes.size()); + d.appIdOffset = AppTicket::kAppTicketAppIdOffset; + d.steamIdOffset = AppTicket::kAppTicketSteamIdOffset; + d.signatureOffset = *reinterpret_cast(d.bytes.data()); + d.signatureSize = AppTicket::kAppTicketSignatureSize; + + if (fetched->steamId != 0) { + entry.identity.foreign = true; + entry.identity.known = true; + entry.identity.steamId = fetched->steamId; + entry.identity.source = "backend"; + } + d.identity = entry.identity; + d.reason = "fetched from the configured ticket backend"; + if (policy.vault) VaultSave(appId, d.bytes, fetched->steamId, "backend"); + return entry; + } + } + + // Closing rung: the requested app's own stored bytes failed validation, + // but an empty answer guarantees the launch failure while a served one + // may still pass. Best-effort mode serves them and records the mixing; + // strict mode stops here. + if (!storedRaw.empty() && !strict) { + d.servable = true; + d.provenance = Provenance::StoredRaw; + d.ownerSteamId = AppTicket::ExtractSteamIdFromTicketBytes(storedRaw); + d.bytes = std::move(storedRaw); + d.totalSize = static_cast(d.bytes.size()); + d.appIdOffset = AppTicket::kAppTicketAppIdOffset; + d.steamIdOffset = AppTicket::kAppTicketSteamIdOffset; + d.signatureOffset = d.bytes.size() >= sizeof(uint32) + ? *reinterpret_cast(d.bytes.data()) + : 0; + d.signatureSize = AppTicket::kAppTicketSignatureSize; + d.mixedIdentity = true; + d.reason = "stored ticket failed validation and nothing else exists; " + "served anyway (best-effort)"; + return entry; + } + + if (entry.identity.foreign) { + d.reason = entry.identity.known + ? "pool identity expected (account " + std::to_string(entry.identity.steamId) + + ") but no signed base for that account exists locally; import setAppTicket " + "from the same source (extract_tickets on the owning account) or re-redeem" + : "no validated stored ticket and no usable forge base"; + } else { + d.reason = "no validated stored ticket and no usable forge base " + "(this is the launch-failure class reported as error 54)"; + } + return entry; + } + + void LogDecision(AppId_t appId, const Entry& entry) + { + const Decision& d = entry.decision; + if (!d.servable) { + LOG_TICKET_WARN("AppId {}: no ownership ticket to serve - {}", appId, d.reason); + return; + } + LOG_TICKET_INFO("AppId {}: serving {} (base AppId {}, owner {}, {} bytes, eticket={}, " + "identity={}{}{}) - {}", + appId, ToString(d.provenance), d.sourceApp, d.ownerSteamId, + d.bytes.size(), entry.hasETicket, + entry.identity.foreign ? "pool/" : "local/", + entry.identity.known ? std::to_string(entry.identity.steamId) + : std::string("unknown"), + d.mixedIdentity ? ", mixed" : "", + d.reason); + } + +} // namespace + +const char* ToString(Provenance provenance) +{ + switch (provenance) { + case Provenance::None: return "none"; + case Provenance::Stored: return "stored"; + case Provenance::StoredVault: return "stored_vault"; + case Provenance::StoredRaw: return "stored_raw"; + case Provenance::ForgeFromHint: return "forge_hint"; + case Provenance::ForgeFromLua: return "forge_lua"; + case Provenance::ForgeFromInstalled: return "forge_installed"; + case Provenance::ForgeFromVault: return "forge_vault"; + case Provenance::ForgeFromRegistry: return "forge_credential"; + case Provenance::ForgeFromClient: return "forge_client"; + case Provenance::ForgeFromCache: return "forge_cached"; + case Provenance::FetchedFromBackend: return "fetch_backend"; + } + return "?"; +} + +const char* ToString(ImportKind kind) +{ + switch (kind) { + case ImportKind::AppTicket: return "appticket"; + case ImportKind::ETicket: return "eticket"; + } + return "?"; +} + +Decision Resolve(AppId_t appId) +{ + // Policy and paths are read before taking our lock: config reloads call + // Reset() after dropping their own lock, so this order can never deadlock. + const Config::TicketPolicy policy = Config::GetTicketPolicy(); + + std::string report; + Decision result; + { + std::lock_guard lock(g_mutex); + if (auto it = g_entries.find(appId); it != g_entries.end()) { + return it->second.decision; + } + + Entry entry = EvaluateLocked(appId, policy); + result = entry.decision; + LogDecision(appId, entry); + g_entries.emplace(appId, std::move(entry)); + + if (policy.report) { + report = BuildReportLocked(policy); + } + } + + if (!report.empty()) FlushReport(report); + return result; +} + +void NoteImport(AppId_t appId, ImportKind kind, const std::vector& blob) +{ + const uint64 local = ActiveAccountSteamId(); + const Config::TicketPolicy policy = Config::GetTicketPolicy(); + + { + std::lock_guard lock(g_mutex); + ImportNote& note = g_imports[appId]; + + if (kind == ImportKind::AppTicket) { + note.hasAppTicket = true; + note.appTicketSteamId = AppTicket::ExtractSteamIdFromTicketBytes(blob); + note.appTicketEmbeddedApp = EmbeddedAppId(blob); + + // Imported material joins the vault immediately: it survives Steam + // cache clears and is available as a forge base or stored ticket. + if (policy.vault) VaultSave(appId, blob, note.appTicketSteamId, "imported"); + + if (note.appTicketEmbeddedApp != 0 && note.appTicketEmbeddedApp != appId) { + LOG_TICKET_WARN("setAppTicket({}): blob embeds AppId {} - it will be ignored for this app", + appId, note.appTicketEmbeddedApp); + } + if (local != 0 && note.appTicketSteamId != 0 && note.appTicketSteamId != local) { + LOG_TICKET_INFO("setAppTicket({}): ticket belongs to account {} (foreign/pool import)", + appId, note.appTicketSteamId); + } + } else { + note.hasETicket = true; + } + + // Material changed for this app: re-evaluate on the next request. + g_entries.erase(appId); + } + + // The checks below read the store through AppTicket (no authority lock + // needed) so importing a lone ETicket is flagged immediately. + if (kind == ImportKind::ETicket) { + std::vector stored = AppTicket::GetAppOwnershipTicketFromCredentialStore(appId); + const bool haveApp = !stored.empty() && AppTicket::IsUsableForgeSourceTicket(stored); + if (!haveApp) { + LOG_TICKET_WARN("setETicket({}): no AppTicket imported for this app; a pool ETicket " + "cannot be paired with a locally forged ticket - import setAppTicket " + "from the same source or re-redeem", appId); + } + } else { + std::vector storedEt = AppTicket::GetEncryptedTicketFromCredentialStore(appId); + if (storedEt.empty()) { + LOG_TICKET_INFO("setAppTicket({}): Denuvo titles also need setETicket from the same " + "account; once an ETicket exists, changing the AppTicket would leave " + "the pair inconsistent", appId); + } + } +} + +uint64 IdentityToReport(AppId_t appId) +{ + (void)Resolve(appId); // ensure the entry exists and is current + + Entry entry; + { + std::lock_guard lock(g_mutex); + auto it = g_entries.find(appId); + if (it == g_entries.end()) return 0; + entry = it->second; + } + + uint64 report = 0; + if (entry.decision.servable) { + report = (entry.decision.provenance == Provenance::Stored) + ? (entry.identity.known ? entry.identity.steamId : entry.decision.ownerSteamId) + : entry.decision.ownerSteamId; + } else if (entry.identity.foreign) { + report = entry.identity.steamId; // 0 when the foreign account is unknown + } + + // Reporting the signed-in account as a "spoof" would be a no-op; keep the + // real identity in that case so the DRM's checks compare like with like. + const uint64 local = ActiveAccountSteamId(); + if (report != 0 && local != 0 && report == local) return 0; + return report; +} + +void Reset() +{ + std::lock_guard lock(g_mutex); + g_entries.clear(); + g_lastGoodBase.reset(); + g_imports.clear(); +} + +void WriteReport() +{ + const Config::TicketPolicy policy = Config::GetTicketPolicy(); + std::string report; + { + std::lock_guard lock(g_mutex); + report = BuildReportLocked(policy); + } + FlushReport(report); +} + +} // namespace TicketAuthority diff --git a/src/Utils/Tickets/TicketAuthority.h b/src/Utils/Tickets/TicketAuthority.h new file mode 100644 index 00000000..1d0537a1 --- /dev/null +++ b/src/Utils/Tickets/TicketAuthority.h @@ -0,0 +1,99 @@ +#pragma once + +#include "Steam/Types.h" + +#include +#include +#include + +// Decides what to answer when Steam asks for an app's ownership ticket. +// +// The authority is the single owner of that decision. It also derives the +// "expected identity" for an app - the account the DRM will check everything +// against - from whatever ticket material exists locally (a stored AppTicket, +// a stored SteamID credential, or ticket material imported this session via +// setAppTicket / setETicket / the redeem flow). The reply, the SteamID spoof +// and the Denuvo identity checks all read the same result, so the bytes handed +// to the DRM and the identity reported alongside them cannot disagree. +// +// A decision is evaluated once per app and cached for the session; config or +// Lua reloads, account switches and new imports reset the cached entry. +namespace TicketAuthority { + + // Where the bytes we serve came from, for logs and the readiness report. + enum class Provenance + { + None, // nothing servable for this app + Stored, // validated per-app ticket from the credential store + StoredVault, // validated per-app ticket from our private vault + StoredRaw, // per-app ticket served despite failing validation (best-effort) + ForgeFromHint, // forged from a base the user listed in [tickets].sources + ForgeFromLua, // forged from the base of an app the Lua setup manages + ForgeFromInstalled, // forged from the base of an app installed on this machine + ForgeFromVault, // forged from a base kept in our private vault + ForgeFromRegistry, // forged from another app's per-app credential + ForgeFromClient, // forged from the Steam client's own cached ticket + ForgeFromCache, // forged from a base that already worked this session + FetchedFromBackend, // signed ticket fetched from a configured ticket backend + }; + + const char* ToString(Provenance provenance); + + // Kind of ticket material handed to us by the config/redeem paths. + enum class ImportKind + { + AppTicket, + ETicket, + }; + + const char* ToString(ImportKind kind); + + // The identity the DRM is expected to see for an app. + struct Identity + { + bool foreign = false; // true: expected account is NOT the signed-in one + bool known = false; // steamId below is valid + uint64 steamId = 0; + std::string source; // where the expectation was derived from + }; + + struct Decision + { + bool servable = false; + bool mixedIdentity = false; // served bytes describe a different account than the expected identity + Provenance provenance = Provenance::None; + Identity identity; // expected identity for the app + AppId_t sourceApp = 0; // app the base blob belongs to (forge only) + uint64 ownerSteamId = 0; // account baked into the served bytes (0 unknown) + std::vector bytes; // exact bytes to hand the DRM + uint32 totalSize = 0; // reply fields, mirroring the ticket layout + uint32 appIdOffset = 0; + uint32 steamIdOffset = 0; + uint32 signatureOffset = 0; + uint32 signatureSize = 0; + std::string reason; // one-line explanation for logs/report + }; + + // Evaluate (or return the cached) decision for appId. + Decision Resolve(AppId_t appId); + + // Called by the Lua setters and the redeem flow when ticket material is + // imported. Validates the pair early (wrong app id, foreign account, or an + // ETicket without its AppTicket) and drops the cached decision for the app. + void NoteImport(AppId_t appId, ImportKind kind, const std::vector& blob); + + // SteamID that must be reported for appId so the DRM sees a single identity + // across all its checks. Returns 0 to keep the real signed-in account (no + // spoof) - the default whenever the expected identity IS the signed-in + // account, and the only option when a foreign identity cannot be identified. + uint64 IdentityToReport(AppId_t appId); + + // Forget every cached decision and import note. Call after config/Lua + // reloads and account switches; the next Resolve() re-evaluates. + void Reset(); + + // Write opensteamtool/tickets.json (respects [tickets].report). Safe to + // call at any time; no-op when the log directory is unavailable. + void WriteReport(); + +} // namespace TicketAuthority diff --git a/src/Utils/Tokeer/TokeerBridge.cpp b/src/Utils/Tokeer/TokeerBridge.cpp index e9e73d19..7e4f768b 100644 --- a/src/Utils/Tokeer/TokeerBridge.cpp +++ b/src/Utils/Tokeer/TokeerBridge.cpp @@ -5,6 +5,7 @@ #include "OSTPlatform/include/Numbers.h" #include "OSTPlatform/include/SteamCredentialStore.h" #include "Utils/Logging/Log.h" +#include "Utils/Tickets/TicketAuthority.h" #include #include @@ -130,6 +131,11 @@ void Redeem(const std::string& code) { LOG_INFO("TokeerBridge: redeemed code for app {} ({} + {} bytes)", *appId, appBytes->size(), etBytes->size()); + // Hand the imported pair to the ticket authority so the identity these + // tickets belong to drives both the served bytes and the spoofed SteamID. + TicketAuthority::NoteImport(*appId, TicketAuthority::ImportKind::AppTicket, *appBytes); + TicketAuthority::NoteImport(*appId, TicketAuthority::ImportKind::ETicket, *etBytes); + const bool launchNow = OSTPlatform::Dialog::ShowConfirm( "Code Redeemed!", "Added app " + std::to_string(*appId) + " to your library.\n\nLaunch it now?"); diff --git a/src/dllmain.cpp b/src/dllmain.cpp index 7110664f..75e0235b 100644 --- a/src/dllmain.cpp +++ b/src/dllmain.cpp @@ -14,34 +14,123 @@ #endif #include "OSTPlatform/include/Dialog.h" #include "OSTPlatform/include/DynamicLibrary.h" +#include "OSTPlatform/include/Encoding.h" #include "OSTPlatform/include/Thread.h" #include #include // prepare key runtime paths. -bool InitializeSteamComponents() +// +// Diversion shadow module: steamclient64.dll is copied to \bin\diversion64.dll +// and the COPY is loaded and hooked, so the original module stays untouched in +// memory. Callers that ask for "steamclient64.dll" are redirected to the copy +// (see Hooks_SteamUI.cpp). If the copy or load fails, the original is loaded +// unchanged ("Diversion inactive") - the process must always end up with a +// working client module. +bool InitializeSteamComponents(OSTPlatform::DynamicLibrary::ModuleHandle selfModule) { - const std::string steamInstallPath = OSTPlatform::DynamicLibrary::GetCurrentDirectoryPath(); + auto steamExeDir = OSTPlatform::DynamicLibrary::GetModuleDirectory(nullptr); + std::string steamInstallPath = OSTPlatform::Encoding::WideToUtf8(steamExeDir.wstring()); + if (steamInstallPath.empty()) { + steamInstallPath = OSTPlatform::DynamicLibrary::GetCurrentDirectoryPath(); + } if (steamInstallPath.empty()) { return false; } sprintf_s(SteamInstallPath, kRuntimePathCapacity, "%s", steamInstallPath.c_str()); - sprintf_s(SteamclientPath, kRuntimePathCapacity, "%s\\steamclient64.dll", SteamInstallPath); - sprintf_s(SteamUIPath, kRuntimePathCapacity, "%s\\steamui.dll", SteamInstallPath); - sprintf_s(DiversionPath, kRuntimePathCapacity, "%s\\bin\\diversion.dll", SteamInstallPath); - sprintf_s(LuaDir, kRuntimePathCapacity, "%s\\config\\stplug-in", SteamInstallPath); - sprintf_s(ConfigPath, kRuntimePathCapacity, "%s\\opensteamtool.toml", SteamInstallPath); - - client_hModule = OSTPlatform::DynamicLibrary::Load(SteamclientPath); + sprintf_s(SteamclientPath, kRuntimePathCapacity, "%s\\steamclient64.dll", SteamInstallPath); + sprintf_s(SteamUIPath, kRuntimePathCapacity, "%s\\steamui.dll", SteamInstallPath); + sprintf_s(DiversionPath, kRuntimePathCapacity, "%s\\bin\\diversion64.dll", SteamInstallPath); + sprintf_s(LuaDir, kRuntimePathCapacity, "%s\\config\\stplug-in", SteamInstallPath); + sprintf_s(ConfigPath, kRuntimePathCapacity, "%s\\opensteamtool.toml", SteamInstallPath); + + auto dllDir = OSTPlatform::DynamicLibrary::GetModuleDirectory(selfModule); + std::string dllPath = OSTPlatform::Encoding::WideToUtf8(dllDir.wstring()); + if (dllPath.empty()) { + dllPath = steamInstallPath; + } + sprintf_s(DllDir, kRuntimePathCapacity, "%s", dllPath.c_str()); + + // Diversion shadow module cloning & loading: + // Clone steamclient64.dll into bin\diversion64.dll so all hooks and patches + // are isolated to the diversion module while original steamclient64.dll stays 100% clean. + WIN32_FILE_ATTRIBUTE_DATA origAttr{}, divAttr{}; + const std::wstring wideSteamclientPath = OSTPlatform::Encoding::Utf8ToWide(SteamclientPath); + const std::wstring wideDiversionPath = OSTPlatform::Encoding::Utf8ToWide(DiversionPath); + const std::wstring wideSteamUIPath = OSTPlatform::Encoding::Utf8ToWide(SteamUIPath); + + const bool origExists = GetFileAttributesExW(wideSteamclientPath.c_str(), GetFileExInfoStandard, &origAttr) != 0; + const bool divExists = GetFileAttributesExW(wideDiversionPath.c_str(), GetFileExInfoStandard, &divAttr) != 0; + + bool isUpToDate = false; + if (origExists && divExists) { + if (origAttr.nFileSizeHigh == divAttr.nFileSizeHigh && + origAttr.nFileSizeLow == divAttr.nFileSizeLow && + origAttr.ftLastWriteTime.dwLowDateTime == divAttr.ftLastWriteTime.dwLowDateTime && + origAttr.ftLastWriteTime.dwHighDateTime == divAttr.ftLastWriteTime.dwHighDateTime) + { + isUpToDate = true; + } + } + + bool copyOk = isUpToDate; + if (isUpToDate) { + LOG_DEBUG("Diversion module is already up to date ({}), skipping copy", DiversionPath); + } else { + std::filesystem::path diversionFsPath(wideDiversionPath); + std::error_code ec; + std::filesystem::create_directories(diversionFsPath.parent_path(), ec); + if (divExists && (divAttr.dwFileAttributes & FILE_ATTRIBUTE_READONLY)) { + SetFileAttributesW(wideDiversionPath.c_str(), FILE_ATTRIBUTE_NORMAL); + } + + // Retry up to 3 times in case the old Steam process is still releasing the file handle + constexpr int kMaxCopyRetries = 3; + DWORD gle = ERROR_SUCCESS; + for (int attempt = 1; attempt <= kMaxCopyRetries; ++attempt) { + if (CopyFileW(wideSteamclientPath.c_str(), wideDiversionPath.c_str(), FALSE)) { + copyOk = true; + LOG_INFO("Cloned steamclient64.dll -> {}", DiversionPath); + break; + } + gle = GetLastError(); + if (gle != ERROR_SHARING_VIOLATION && gle != ERROR_ACCESS_DENIED) { + break; + } + if (attempt < kMaxCopyRetries) { + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + } + + if (!copyOk) { + LOG_WARN("CopyFileW to diversion64.dll failed after {} attempts (err={})", kMaxCopyRetries, gle); + } + } + + if (copyOk) { + client_hModule = OSTPlatform::DynamicLibrary::Load(std::filesystem::path(wideDiversionPath)); + if (client_hModule) { + g_IsDiversionActive.store(true, std::memory_order_release); + LOG_INFO("Loaded diversion module from {}", DiversionPath); + } else { + LOG_WARN("Load diversion module failed (path={}, err={}), falling back to real steamclient64.dll", + DiversionPath, OSTPlatform::DynamicLibrary::GetLastErrorCode()); + } + } + if (!client_hModule) { - LOG_ERROR("Load steamclient64.dll failed: {} (err={})", - SteamclientPath, OSTPlatform::DynamicLibrary::GetLastErrorCode()); - return false; + g_IsDiversionActive.store(false, std::memory_order_release); + client_hModule = OSTPlatform::DynamicLibrary::Load(std::filesystem::path(wideSteamclientPath)); + if (!client_hModule) { + LOG_ERROR("Load steamclient64.dll failed: {} (err={})", + SteamclientPath, OSTPlatform::DynamicLibrary::GetLastErrorCode()); + return false; + } + LOG_INFO("Loaded fallback steamclient64.dll from {} (Diversion inactive)", SteamclientPath); } - LOG_INFO("Loaded steamclient64.dll from {}", SteamclientPath); - - ui_hModule = OSTPlatform::DynamicLibrary::Load(SteamUIPath); + + ui_hModule = OSTPlatform::DynamicLibrary::Load(std::filesystem::path(wideSteamUIPath)); if(!ui_hModule) { LOG_ERROR("Load failed for steamui.dll: err={}", OSTPlatform::DynamicLibrary::GetLastErrorCode()); return false; @@ -56,8 +145,10 @@ static uint32_t InitThread(OSTPlatform::DynamicLibrary::ModuleHandle selfModule) Log::Init(selfModule); LOG_INFO("OpenSteamTool init thread started"); - if (!InitializeSteamComponents()) { + if (!InitializeSteamComponents(selfModule)) { LOG_ERROR("InitializeSteamComponents failed"); + // Release the SteamUI LoadModuleWithPath waiters; nothing else will. + g_HooksInstalled.store(true, std::memory_order_release); return 1; } @@ -73,6 +164,10 @@ static uint32_t InitThread(OSTPlatform::DynamicLibrary::ModuleHandle selfModule) PatternLoader::Load(ui_hModule, SteamUIPath, "steamui"); PatternLoader::Load(client_hModule, SteamclientPath, "steamclient"); + // Install SteamUI hooks early so LoadModuleWithPath can intercept + // and synchronize with client hook installation. + SteamUI::CoreHook(); + // IPC method metadata (funcHash, fencepost, argc, ...) IPCLoader::Load(SteamclientPath); @@ -84,7 +179,6 @@ static uint32_t InitThread(OSTPlatform::DynamicLibrary::ModuleHandle selfModule) LuaFileWatcher::Start(watchDirs); ConfigFileWatcher::Start(ConfigPath, LuaDir); - SteamUI::CoreHook(); SteamClient::CoreHook(); // Surface any functions that FindPattern() could not locate. @@ -132,7 +226,11 @@ static uint32_t InitThread(OSTPlatform::DynamicLibrary::ModuleHandle selfModule) LOG_INFO("Self-updater not compiled in (OST_ENABLE_UPDATER=OFF)"); #endif - LOG_INFO("OpenSteamTool init complete"); + g_HooksInstalled.store(true, std::memory_order_release); + LOG_INFO("OpenSteamTool init complete ({})", + g_IsDiversionActive.load(std::memory_order_acquire) + ? "Diversion active" + : "Diversion bypassed, using original steamclient64"); return 0; } @@ -156,6 +254,14 @@ BOOL APIENTRY DllMain(HMODULE hModule, DWORD dwReason, PVOID pvReserved) DisableThreadLibraryCalls(hModule); if (!IsSteamHost()) return TRUE; // e.g. rundll32 bst:// handler — no injection here + + // Keep this module pinned so an explicit FreeLibrary cannot unload code + // while hooks and worker threads may still reference it. + HMODULE pinnedModule = nullptr; + GetModuleHandleExA( + GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_PIN, + reinterpret_cast(&DllMain), &pinnedModule); + // Hand off all real work to a worker thread to avoid running file I/O, // module loading and detour transactions under the loader lock. OSTPlatform::Thread::StartDetached([module = reinterpret_cast(hModule)] { @@ -164,11 +270,19 @@ BOOL APIENTRY DllMain(HMODULE hModule, DWORD dwReason, PVOID pvReserved) } else if (dwReason == DLL_PROCESS_DETACH && IsSteamHost()) { - ConfigFileWatcher::Stop(); - LuaFileWatcher::Stop(); - SteamUI::CoreUnhook(); - SteamClient::CoreUnhook(); - CloudRedirectHost::Shutdown(); + g_HooksInstalled.store(false, std::memory_order_release); + g_IsDiversionActive.store(false, std::memory_order_release); + + // When pvReserved != nullptr the process is terminating: all other + // threads are already gone and detour cleanup / thread joins under the + // loader lock can deadlock. Skip cleanup; the OS reclaims everything. + if (pvReserved == nullptr) { + ConfigFileWatcher::Stop(); + LuaFileWatcher::Stop(); + SteamUI::CoreUnhook(); + SteamClient::CoreUnhook(); + CloudRedirectHost::Shutdown(); + } } return TRUE; diff --git a/src/dllmain.h b/src/dllmain.h index f141f78d..b1250853 100644 --- a/src/dllmain.h +++ b/src/dllmain.h @@ -13,6 +13,8 @@ #include #include #include +#include +#include #include #include "Steam/Types.h" @@ -27,6 +29,13 @@ inline OSTPlatform::DynamicLibrary::ModuleHandle client_hModule = nullptr; inline OSTPlatform::DynamicLibrary::ModuleHandle ui_hModule = nullptr; +// Diversion/startup synchronization: g_HooksInstalled is +// set when hook installation has completed (or failed); the SteamUI +// LoadModuleWithPath hook waits on it. g_IsDiversionActive is true while the +// cloned steamclient64.dll (bin\diversion64.dll) is the loaded client module. +inline std::atomic g_HooksInstalled{false}; +inline std::atomic g_IsDiversionActive{false}; + inline constexpr size_t kRuntimePathCapacity = 260; inline char SteamInstallPath[kRuntimePathCapacity] = {}; @@ -35,6 +44,7 @@ inline char SteamUIPath[kRuntimePathCapacity] = {}; inline char DiversionPath[kRuntimePathCapacity] = {}; inline char LuaDir[kRuntimePathCapacity] = {}; inline char ConfigPath[kRuntimePathCapacity] = {}; +inline char DllDir[kRuntimePathCapacity] = {}; // The fake AppId used by -onlinefix (SpaceWar). constexpr AppId_t kOnlineFixAppId = 480;