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..d35e51ea 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,41 @@ namespace { LOG_IPC_DEBUG("IClientUser::GetAppOwnershipTicketExtendedData:{}", req.DebugString()); if (req.cbMaxTicket() < 0) return; - AppTicket::AppOwnershipTicket ticket{}; AppId_t appId = req.unAppID() == kOnlineFixAppId ? Hooks_Misc::ResolveAppId() : req.unAppID(); - - AppTicket::AppTicketSource ticketSource; - if (PipeManager::DenuvoAuth::IsAuthorizedPipe(pipe)) { - ticketSource = AppTicket::AppTicketSource::CredentialStoreOnly; - } else { - // Outside the auth window: prefer credential-store ticket (pool SteamID) - // over ForgeOnly (which uses app 7's ticket and carries the real SteamID). - // When the 858 network spoof is also active, both paths must agree on the - // same SteamID or Denuvo cross-checks them and rejects (error 54). - LOG_IPC_DEBUG("IClientUser::GetAppOwnershipTicketExtendedData: AppId={} not in authorization window, credential store preferred", appId); - ticketSource = AppTicket::AppTicketSource::CredentialStoreThenForge; - } - if (!AppTicket::GetAppOwnershipTicket(appId, ticket, ticketSource)) return; - - if (ticket.data.size() > static_cast(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); - - LOG_IPC_DEBUG("IClientUser::GetAppOwnershipTicketExtendedData: AppId={} {}", - appId,resp.DebugString()); + resp.set_returnValue(decision.totalSize); + if (!resp.set_pTicket(decision.bytes)) return; + resp.set_piAppId(decision.appIdOffset); + resp.set_piSteamId(decision.steamIdOffset); + resp.set_piSignature(decision.signatureOffset); + resp.set_pcbSignature(decision.signatureSize); + + LOG_IPC_INFO("IClientUser::GetAppOwnershipTicketExtendedData: AppId={} served {} ({} bytes, base AppId {}, owner {}{})", + appId, TicketAuthority::ToString(decision.provenance), decision.bytes.size(), + decision.sourceApp, decision.ownerSteamId, + decision.mixedIdentity ? ", mixed identity" : ""); } // [Post-Handler]: IClientUser::RequestEncryptedAppTicket @@ -111,10 +113,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/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/Config/Config.cpp b/src/Utils/Config/Config.cpp index fc84b696..92264dfd 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,17 @@ namespace { snapshot.cloud.library = *val; } + // [tickets] + if (auto tickets = tbl["tickets"].as_table()) { + if (auto v = (*tickets)["identity_mode"].value()) { + // "best_effort" (default) serves the best available bytes even + // when identities mix; "strict" refuses such answers instead. + if (*v == "strict") snapshot.ticketPolicy.allowMixedIdentity = false; + else if (*v == "best_effort") snapshot.ticketPolicy.allowMixedIdentity = true; + else LOG_WARN("[tickets] identity_mode \"{}\" unknown (use \"best_effort\" or \"strict\")", *v); + } + } + ApplyManifestProvider(snapshot.manifestProvider); LoadResult result = ApplySnapshotLocked(snapshot); LOG_INFO("Config loaded: manifest.url={} log.level={} lua.paths={} stats.enable_api={} remote.url_templates={}", @@ -299,6 +319,11 @@ namespace { return updateEnabled; } + TicketPolicy GetTicketPolicy() { + std::lock_guard lock(g_mutex); + return ticketPolicy; + } + CloudSettings GetCloudSettings() { std::lock_guard lock(g_mutex); return { diff --git a/src/Utils/Config/Config.h b/src/Utils/Config/Config.h index ad6fbe03..3857321c 100644 --- a/src/Utils/Config/Config.h +++ b/src/Utils/Config/Config.h @@ -26,6 +26,13 @@ namespace Config { bool allGames = false; // false: only Lua-unlocked games }; + // [tickets] - how the ownership-ticket reply is decided (TicketAuthority). + struct TicketPolicy { + bool allowMixedIdentity = true; // identity_mode "best_effort" (default) serves the + // best available bytes; "strict" refuses answers + // whose identities would mix + }; + struct CloudSettings { bool enabled = false; std::string library; @@ -46,6 +53,7 @@ namespace Config { CloudSettings GetCloudSettings(); bool GetStatsEnableApi(); bool GetUpdateEnabled(); + TicketPolicy GetTicketPolicy(); // [donate] — contribute manifest request codes for depots this account owns. struct DonateSettings { @@ -83,6 +91,9 @@ namespace Config { // [update] - self-update check on startup (staged for next Steam launch). inline bool updateEnabled = true; + // [tickets] - ownership-ticket answer policy (see TicketAuthority). + inline TicketPolicy ticketPolicy; + // [donate] - mint manifest request codes on request for depots this account // owns. Codes are bound to (depot, manifest) and rotate within minutes, so // they are minted on demand and sent straight on, never stored. The caps 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/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..ae585421 --- /dev/null +++ b/src/Utils/Tickets/TicketAuthority.cpp @@ -0,0 +1,608 @@ +#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 + +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 + }; + + 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 + }; + + // A forge base candidate: which storage holds the blob and which provider + // discovered it (only used for the log line). + struct Candidate + { + BaseKind kind; + AppId_t appId; + int rank; + const char* providerName; + }; + + 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 -------------------------------------------------------------- + + bool ReadBase(BaseKind kind, AppId_t appId, std::vector& out) + { + if (kind == BaseKind::ConfigStore) { + out = Hooks_Decryption::GetCacheAppOwnershipTicket(appId); + } 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); + } + + // 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; + } + + // 1) Stored bytes. This is the only source that carries an account's + // identity directly, so it is preferred whenever it validates; a blob + // that fails validation is kept in storedRaw as a last resort. + bool TryStoredLocked(AppId_t appId, Entry& entry, std::vector& storedRaw) + { + std::vector stored = AppTicket::GetAppOwnershipTicketFromCredentialStore(appId); + if (stored.empty()) return false; + + 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); + return false; + } + if (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); + return false; + } + + Decision& d = entry.decision; + d.servable = true; + d.provenance = Provenance::Stored; + d.ownerSteamId = AppTicket::ExtractSteamIdFromTicketBytes(stored); + d.totalSize = static_cast(stored.size()); + d.signatureOffset = *reinterpret_cast(stored.data()); + d.bytes = std::move(stored); + return true; + } + + // 2) Forge-base candidates, in preference order. There is deliberately no + // fixed app id list in the DLL - ids are discovered: + // Lua-managed apps -> per-app credentials (probed in BOTH storage + // locations) -> the Steam client's own ticket (last local resort). + std::vector CollectCandidatesLocked(AppId_t appId) + { + std::vector candidates; + auto add = [&](BaseKind kind, AppId_t app, int rank, const char* name) { + 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, name }); + }; + + { + // 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; + add(BaseKind::ConfigStore, app, 0, "lua"); + ++added; + } + } + + { + // Every id the credential store knows about is probed in both + // storage locations, so a cache-only blob cannot hide just because + // no registry credential exists for it. + std::vector credentials; + if (OSTPlatform::SteamCredentialStore::EnumerateAppIds(credentials) + == OSTPlatform::SteamCredentialStore::Status::Ok) { + for (uint32_t credential : credentials) { + const AppId_t app = static_cast(credential); + add(BaseKind::CredentialStore, app, 1, "credential"); + add(BaseKind::ConfigStore, app, 1, "credential"); + } + } + } + + add(BaseKind::ConfigStore, kClientAppId, 2, "client"); + add(BaseKind::CredentialStore, kClientAppId, 2, "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 + }); + return candidates; + } + + bool BaseMatchesIdentity(const Entry& entry, uint64 baseSteamId, uint64 localSteamId) + { + if (entry.identity.foreign) { + return entry.identity.steamId != 0 && baseSteamId == entry.identity.steamId; + } + // Local identity: an unknown-account base is acceptable, a known one + // must be the signed-in account. + return baseSteamId == 0 || localSteamId == 0 || baseSteamId == localSteamId; + } + + bool TryBaseLocked(AppId_t appId, Entry& entry, const Candidate& candidate, + bool requireMatch, uint64 localSteamId) + { + std::vector base; + if (!ReadBase(candidate.kind, candidate.appId, base)) return false; + if (!AppTicket::IsUsableForgeSourceTicket(base)) return false; + + const uint64 baseSteamId = AppTicket::ExtractSteamIdFromTicketBytes(base); + if (requireMatch && !BaseMatchesIdentity(entry, baseSteamId, localSteamId)) { + LOG_TICKET_DEBUG("AppId {}: base AppId {} (account {}) does not match the expected identity, skipping", + appId, candidate.appId, baseSteamId); + return false; + } + + std::vector forged = AppTicket::ForgeFromSource(appId, base); + if (forged.empty()) return false; + + Decision& d = entry.decision; + d.servable = true; + d.provenance = Provenance::Forged; + d.sourceApp = candidate.appId; + 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 = !matchesExpected && baseSteamId != 0; + return true; + } + + bool ForgeLocked(AppId_t appId, Entry& entry, const std::vector& candidates, + bool requireMatch, uint64 localSteamId) + { + for (const Candidate& candidate : candidates) { + if (TryBaseLocked(appId, entry, candidate, requireMatch, localSteamId)) { + g_lastGoodBase = BaseRef{ candidate.kind, candidate.appId }; + entry.decision.reason = std::string("forged from ") + candidate.providerName + " base"; + return true; + } + } + return false; + } + + // 3) 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. + bool TryBackendLocked(AppId_t appId, Entry& entry) + { + const uint64 existingSteamId = entry.identity.known ? entry.identity.steamId : 0; + auto fetched = EticketClient::FetchTickets(appId, {}, existingSteamId); + if (!fetched || !AppTicket::IsUsableForgeSourceTicket(fetched->ownership)) return false; + + Decision& d = entry.decision; + d.servable = true; + d.provenance = Provenance::Forged; + 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"; + return true; + } + + // -- 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; + } + + std::vector storedRaw; // validation failures kept for a last-resort rung + if (TryStoredLocked(appId, entry, storedRaw)) { + d.reason = "validated stored ticket"; + 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.allowMixedIdentity; + const uint64 localSteamId = ActiveAccountSteamId(); + + 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; + } + + bool served = false; + + // The base that already worked earlier in this session is tried first. + if (g_lastGoodBase.has_value()) { + const Candidate cached{ g_lastGoodBase->kind, g_lastGoodBase->appId, 0, "cached" }; + served = TryBaseLocked(appId, entry, cached, false, localSteamId); + if (served) d.reason = "forged from base that already worked this session"; + } + + if (!served) { + const std::vector candidates = CollectCandidatesLocked(appId); + const bool matchingFirst = entry.identity.known && entry.identity.steamId != 0; + if (matchingFirst) { + served = ForgeLocked(appId, entry, candidates, true, localSteamId); + } + if (!served && !strict) { + // Best-effort pass: accept any usable base and record the mixing. + served = ForgeLocked(appId, entry, candidates, false, localSteamId); + } + if (!served && strict && !matchingFirst) { + // Strict mode with an unidentifiable expected account (e.g. the + // active-user key is unavailable): mirror the historical behavior. + served = ForgeLocked(appId, entry, candidates, true, localSteamId); + } + if (served) return entry; + } else { + return entry; + } + + if (TryBackendLocked(appId, entry)) 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::Stored; + 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::Forged: return "forged"; + } + 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 is 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::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); + LogDecision(appId, entry); + const Decision result = entry.decision; + g_entries.emplace(appId, std::move(entry)); + return result; +} + +void NoteImport(AppId_t appId, ImportKind kind, const std::vector& blob) +{ + const uint64 local = ActiveAccountSteamId(); + + { + 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); + + 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; + } + + // The spoof must describe the bytes actually served: the account baked into + // them. When nothing is served but a foreign identity is known, report that + // identity so the DRM's checks compare like with like. + uint64 report = 0; + if (entry.decision.servable) { + report = entry.decision.ownerSteamId; + } else if (entry.identity.foreign) { + report = entry.identity.steamId; + } + + // Reporting the signed-in account as a "spoof" would be a no-op; keep the + // real identity in that case. + 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(); +} + +} // namespace TicketAuthority diff --git a/src/Utils/Tickets/TicketAuthority.h b/src/Utils/Tickets/TicketAuthority.h new file mode 100644 index 00000000..0e9cd6a1 --- /dev/null +++ b/src/Utils/Tickets/TicketAuthority.h @@ -0,0 +1,86 @@ +#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 and the SteamID +// spoof 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 served came from, for the log line. + enum class Provenance + { + None, // nothing servable for this app + Stored, // per-app ticket from the credential store + Forged, // forged from another app's cached signed material + }; + + 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 the log + }; + + // 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(); + +} // 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?");