Goldberg 模拟器新版 Steam Auth Ticket 深度解析:格式、GC Token、签名与配置实践
Goldberg 模拟器新版 Steam Auth Ticket 深度解析:格式、GC Token、签名与配置实践
本篇技术指南围绕 Goldberg Steam Emulator 分支(gbe_fork)中全新的 App Ticket(应用认证票据)格式展开,完整剖析新旧票据的二进制布局差异、Game Coordinator(GC)令牌段、DLC 与 License 编码、RSA 签名流程,并结合仓库源码(dll/auth.cpp、dll/dll/auth.h)与配置项(new_app_ticket、gc_token)给出可直接落地的实操方案。读完本文,你将能够理解并复现新版票据的生成逻辑、自行解析任意票据字节流,并掌握在多 DLC 场景下配置票据缓冲区大小的正确姿势。
为什么需要新版 Auth Ticket
旧版票据的核心问题是不含任何有效载荷:它既不记录票据生成时间,也不携带 DLC 列表、AppID 等信息。因此,凡是会在票据内部做校验的模拟器或服务端(例如 Nemirtingas Epic Emu 这类第三方模拟器),都无法接受旧格式的票据。
简单来说,旧版票据只是一串"我是谁"的标识,而新版票据才是"我是谁 + 我拥有什么 + 什么时间生成的"完整凭证。这也是 Goldberg 模拟器引入 enable_new_app_ticket(配置项 new_app_ticket)的根本原因。
新旧票据的形态对比
旧版票据:仅 20 字节
旧版票据的字节布局如下:
4 byte (header) | 4 byte | 8 byte
0x14 (AKA 20) 00 00 00 | [TicketNumber] | [SteamId]
- 前 4 字节是魔术头
0x14(十进制 20),即整段数据长度; - 中间 4 字节是票据编号;
- 末尾 8 字节是 SteamID。
这段结构在源码中对应 dll/auth.cpp 的宏定义:
#define STEAM_ID_OFFSET_TICKET (4 + 8)
#define STEAM_TICKET_MIN_SIZE (4 + 8 + 8)
STEAM_ID_OFFSET_TICKET = 12 正是跳过头 4 字节和 TicketNumber 4 字节后 SteamID 的起始偏移;STEAM_TICKET_MIN_SIZE = 20 则是旧票据的最小长度。旧票据的生成逻辑见 dll/auth.cpp:头字节固定为 0x14,随后写入 SteamID 与自增的票据编号。它不含任何"何时生成、拥有哪些 DLC、启动的是哪个 AppID"的信息——这正是旧票据无法满足严格校验方需求的原因。
GC(Game Coordinator)是什么
GC 即 Game Coordinator(游戏协调器)。在真实 Steam 架构中,它辅助匹配 IP 地址、优化 Matchmaking(匹配)与其他交互,Steam 通常会把这类数据回传。GC 段的作用在于让票据携带更贴近真实 Steam 的上下文信息。
开发者通过编写一个小型应用、在 steam_api.txt 中填入自己拥有的任意 AppID,即可从 Steam 抓取真实票据用于逆向研究,这是文档中给出的原始研究手段。
新版票据的 GC 段:固定 52 字节
GC 段结构如下(见 dll/dll/auth.h 中的 AppTicketGC 结构与 dll/auth.cpp 的序列化实现):
uint32_t STEAM_APPTICKET_GCLen = 20; // Magic header 20
uint64_t GCToken{}; // A unique token for this, can be random or sequential
CSteamID id{}; // our steamId
uint32_t ticketGenDate{}; //epoch time when generated
uint32_t STEAM_APPTICKET_SESSIONLEN = 24; // Magic Header 24
uint32_t one = 1; // dont know yet
uint32_t two = 2; // dont know yet
uint32_t ExternalIP{}; // External ip (Steam usually encrypting these)
uint32_t InternalIP{}; // Internal ip (Steam usually encrypting these)
uint32_t TimeSinceStartup{}; // Seconds since Steam Startup
uint32_t TicketGeneratedCount{}; // how many ticket did you generated since startup
uint32_t FullSizeOfGC = 56; // GC size (52) + 4
字段相加共 52 字节,字节账目如下:
8 = uint64_t 的长度
4 = uint32_t 的长度
4 + 8 + 8 + 4 = 24(去掉头部 4 字节后,这一节的净长度为 20,对应 STEAM_APPTICKET_GCLen = 20)
4 + 4 + 4 + 4 + 4 + 4 + 4 = 28(去掉头部 4 字节后,这一节的净长度为 24,对应 STEAM_APPTICKET_SESSIONLEN = 24)
源码中通过 static_assert(total_size == 52) 在编译期强制保证 GC 段序列化后恰好为 52 字节(dll/auth.cpp),任何平台字节宽度偏差都会直接编译失败,这保证了格式的确定性。
票据主体(Ticket):静态 42 字节 + 动态列表
在 dll/dll/auth.h 中,AppTicket 结构定义了票据主体:
uint32_t TheTicketLenght; // Full lenght of the ticket exluding the padding and the Singature
uint32_t Version{}; // Latest version is 4 so we keep that way
CSteamID id{}; // our steamId
uint32_t AppId{}; // Current AppId that we playing
uint32_t ExternalIP{}; // External ip (Steam usually encrypting these)
uint32_t InternalIP{}; // Internal ip (Steam usually encrypting these)
uint32_t AlwaysZero = 0; //OwnershipFlags? or Might be VAC Banned?
uint32_t TicketGeneratedDate{}; // Epoch Seconds when the Ticket generated
uint32_t TicketGeneratedExpireDate{}; // Epoch Seconds when the Ticket will expire
std::vector<uint32_t> Licenses{}; // our licenses (Usually is 0 or if you own a locked beta that will be it)
std::vector<DLC> DLCs{}; // what DLC we own
DLC 子结构(dll/dll/auth.h):
struct DLC {
uint32_t AppId{}; // AppId of the DLC
std::vector<uint32_t> Licenses{}; // Again license what you own, usually 0 or nothing inside
};
关于 Licenses 的误解:所有未公开发行(unreleased to public)的应用都处于某个 License 之下。Steam 通常会设置(或返回)0 来表示"你拥有它"或"你没有对应 License"。需要注意:License 为 0 绝不代表应用是免费的——即使你已购买,票据中显示的 License 依然可能是 0!
序列化时,票据主体的静态字段(Version、SteamID、AppId、内外 IP、AlwaysZero、生成时间、过期时间、License 数量、DLC 数量与 2 字节 padding)合计必须为 42 字节,同样以 static_assert(static_fields_size == 42) 编译期校验(dll/auth.cpp)。随后依次写入 License 数组(每项 4 字节)与 DLC 数组,DLC 数组的元素布局为:
| 4 bytes: app id
| 2 bytes: DLC licenses count
[ 4 bytes × N: DLC license elements ]
完整布局注释见 dll/auth.cpp。
签名与 Padding
Steam 的票据签名长度为 128 字节(源码常量 STEAM_APPTICKET_SIGLEN = 128,见 dll/dll/auth.h)。本项目采用 RSA1 + PKCS1 签名方案,使用的私钥内嵌在 dll/auth.cpp 的 app_ticket_key 字符串中(原始出处为 Detanup01 的 stmsrv 项目 Cert/AppTicket.key)。
签名流程(sign_auth_data,dll/auth.cpp):
- 对票据数据做 SHA-1 哈希(
mbedtls_sha1,mbedtls 封装见 dll/dll/include.wrap.mbedtls.h); - 初始化 mbedtls 熵源与 CTR-DRBG 随机数上下文;
- 解析 PEM 私钥并对哈希后的数据进行 PKCS1 签名;
- 将 128 字节签名追加到票据数据末尾(dll/auth.cpp)。
Padding:票据静态字段末尾有一个 2 字节 padding(固定为 0,见 dll/auth.cpp)。文档作者推测这可能是某种标志位,也可能与 VAC 封禁状态相关,但尚未确认其真实含义——目前实现只是固定写入 0。
最小尺寸的推导:128(签名)+ 42(无 DLC、无 License、无 GC 的票据主体)= 170 字节。因此新版票据最小长度为 170,源码中以 STEAM_TICKET_MIN_SIZE_NEW 170 定义并在 getTicket 中校验 cbMaxTicket < STEAM_TICKET_MIN_SIZE_NEW 时直接返回 k_HAuthTicketInvalid(dll/auth.cpp)。
整体装配布局
Auth_Data::Serialize()(dll/auth.cpp)给出了最终票据的完整布局:
带 GC 时:
X bytes: GC 数据块(当前 52 字节)
4 bytes: 剩余 Auth_Data 块长度(= 4 + Y + Z,注意减去整个 GC layout 长度以免把自身算进去)
4 bytes: 票据数据布局长度(= 票据 blob + 4,自身包含在内)
Y bytes: 票据数据 blob
Z bytes: 票据签名(128 字节)
不带 GC 时:
4 bytes: 票据数据布局长度
Y bytes: 票据数据 blob
Z bytes: 票据签名(128 字节)
触发新版票据的配置方式
新版票据默认关闭。默认情况下模拟器对以下 API 均发送旧格式票据:
Steam_GameServer::GetAuthSessionTicket()Steam_User::GetAuthSessionTicket()Steam_User::GetAuthTicketForWebApi()
要启用新版票据,编辑配置文件 configs.main.ini(模板见 post_build/steam_settings.EXAMPLE/configs.main.EXAMPLE.ini):
new_app_ticket=1
gc_token=1
new_app_ticket=1:启用新版票据生成(对应源码设置项 dll/dll/settings.h 的enable_new_app_ticket);gc_token=1:额外在票据中附带 GC 段(对应设置项use_gc_token)。
这两个开关由 dll/settings_parser.cpp 从 main::general 节解析,同时作用于客户端与服务端两份配置。官方发布说明见 post_build/README.release.md。
票据生成时的实际填充值
在 dll/auth.cpp 的 getTicketData 中,新版票据的字段填充逻辑是:
Version固定为 4(当前最新版本);AppId取当前本地游戏 ID(settings->get_local_game_id().AppID());- 内外 IP 暂以回环地址
127.0.0.1(小端 DWORD 形式)填充,源码标注为 TODO; AlwaysZero恒为 0;TicketGeneratedDate为当前 Unix 时间戳,TicketGeneratedExpireDate为生成时间 +24 小时;Licenses初始化为空后压入一个 0(TODO 标记);DLCs遍历settings->DLCCount()获取的 DLC 列表逐个填充 AppId,DLC 的 License 暂为空(TODO);- 启用
gc_token时,GCToken为随机整数,TimeSinceStartup为自模拟器启动以来的秒数,TicketGeneratedCount为自启动以来生成的票据计数(dll/auth.cpp)。
票据缓冲区大小:1024 还是 2048?
若用户拥有大量 DLC,票据很容易超过 1024 字节。Steam 官方推荐缓冲区为 1024,但文档明确建议:如果游戏 DLC 众多(如 PayDay 2),请将缓冲区设置为 2048。
源码中的缓冲区常量也印证了这一点——dll/auth.cpp 定义 STEAM_AUTH_TICKET_SIZE 256,注释提到 Conan Exiles 无法适配 512 或 128、256 才是合适的尺寸,同时注明 Steam 通常按 1024 发送。即:缓冲区尺寸需按目标游戏的 DLC 数量灵活调整,不存在一劳永逸的"万能值"。
有趣的事实与边界行为
旧票据与新票据的兼容关系
旧票据的头部结构与新版票据的 GC 段开头非常相似。正因如此,SendUserConnectAndAuthenticate 与 beginAuth 目前并没有为解析新版票据编写专门代码(dll/auth.cpp):beginAuth 只按固定偏移读取 SteamID(偏移 12)与票据编号(偏移 8)即可完成认证,根本无需反序列化票据内容。这意味着:
- 对出站方向(模拟器发给游戏/服务端),新票据携带了丰富的 DLC/时间戳信息,可满足第三方模拟器的校验;
- 对入站方向(模拟器收到他人票据),旧版解析逻辑依旧够用,因为新旧票据在头部结构上同源。
票据编号的生成
generate_steam_ticket_id()(dll/auth.cpp)从 1 开始自增;编号 0 被保留给 k_HAuthTicketInvalid(认证 API 失败时的返回值),因此实现中显式跳过了 0。beginAuth 还会利用 STEAM_TICKET_PROCESS_TIME = 0.03 秒的去重窗口拦截重复请求,返回 k_EBeginAuthSessionResultDuplicateRequest。
小结
- 旧版票据 = 20 字节无信息载荷;新版票据 = GC 段(可选,52 字节)+ 票据主体(42 字节起)+ 128 字节 RSA 签名,最小 170 字节;
- 启用方式:
configs.main.ini中new_app_ticket=1(与gc_token=1搭配); - License 为 0 不代表免费,只表示"无额外许可";
- 多 DLC 游戏请把票据缓冲区上调至 2048;
- 票据生成、序列化与签名的全部实现集中在 dll/auth.cpp 与 dll/dll/auth.h,配置解析在 dll/settings_parser.cpp,官方发布说明见 post_build/README.release.md。