Validation Report — Steam CM/GC Research
Audit pass over Steam Protocol Overview, TCP Transport and Handshake, Auth v2 Login Flow, CS2 Game Coordinator against the four upstream repos. Every claim re-checked against the actual code. Each row below has been verified (or invalidated) with a direct grep/read of the source.
For Agents
This is the per-claim audit trail produced after the original research pass. If you are a downstream consumer of these notes, read this report alongside Steam Protocol Overview to know which claims are verified and which are still empirical hypotheses. The most consequential corrections are: four wrong EMsg values, the heartbeat field name, and two CS2 GC claims (Prime detection mechanism, Premier
rank_type_id) that were downgraded from “fact” to “unverified”.
Legend:
- Verified — checked against the source code as written.
- Verified with caveat — read the notes.
- Wrong in the original drafts — fix described in “Correction”.
1. TCP transport (Layer 1)
| Claim | Status | Source / Correction |
|---|---|---|
| TCP frame is 4-byte LE length + 4-byte magic + payload | Verified | TcpConnection.cs:327-328, tcp.js:169-172 |
| Magic = “VT01” = 0x31305456 LE | Verified | TcpConnection.cs:16 (const uint MAGIC = 0x31305456), tcp.js:9 (const MAGIC = 'VT01') |
| Header is unencrypted, payload may be encrypted | Verified | Per receive loop in both impls |
| Magic mismatch → connection torn down | Verified | TcpConnection.cs:295, tcp.js:194 |
2. Handshake messages (Layer 1b setup)
| Claim | Status | Source / Correction |
|---|---|---|
EMsg.ChannelEncryptRequest = 1303 | Verified | EMsg.js:486 |
EMsg.ChannelEncryptResponse = 1304 | Verified | EMsg.js:487 |
EMsg.ChannelEncryptResult = 1305 | Verified | EMsg.js:488 |
| Universe values 1=Public, 2=Beta, 3=Internal, 4=Dev | Verified | EUniverse enum |
Handshake uses MsgHdr (20 bytes), not protobuf | Verified | SteamLanguageInternal.cs:228; MsgHdrProtoBuf is the protobuf variant at line 332 |
| ChannelEncryptResult must be validated == OK before flipping into encrypted mode | Verified | EnvelopeEncryptedConnection.cs:194-211 |
3. Crypto (Layer 1b runtime)
| Claim | Status | Source / Correction |
|---|---|---|
| 32-byte AES-256 session key, randomly generated | Verified | EnvelopeEncryptedConnection.cs:157 |
RSA wraps sessionKey ‖ randomChallenge | Verified | EnvelopeEncryptedConnection.cs:164-166 |
| RSA padding = OAEP with SHA-1 | Verified | EnvelopeEncryptedConnection.cs:168 (RSAEncryptionPadding.OaepSHA1) |
| RSA modulus = 1024 bits, exponent = 0x11 (17) | Verified | KeyDictionary.cs per-universe |
| CRC32 over the encrypted RSA blob | Verified | EnvelopeEncryptedConnection.cs:171 |
| HMAC key = first 16 bytes of session key | Verified | NetFilterEncryptionWithHMAC.cs:32-33 — verified, not the last 16. |
| IV = HMAC-SHA1(key=hmacSecret, msg=random3‖plaintext)[0..13] ‖ random3 | Verified | NetFilterEncryptionWithHMAC.cs:92-118 |
| AES mode = CBC, PKCS7 padding for body; ECB-no-padding for IV-of-IV | Verified | NetFilterEncryptionWithHMAC.cs:76-90 |
4. CM-level EMsg values (used after the handshake)
| Claim | Status | Source / Correction |
|---|---|---|
EMsg.ClientLogon = 5514 | Verified | EMsg.js:1380 |
EMsg.ClientLogonResponse = 751 | Verified with caveat | Verified, but spelled "ClientLogOnResponse" (capital On) in EMsg.js:307. Functionally identical. |
EMsg.ClientHeartBeat = 703 | Verified | EMsg.js:266 |
EMsg.ClientGamesPlayed = 5410 | Wrong | Actual value is 742 (EMsg.js:299). The original draft wrote 5410 — wrong. |
EMsg.ServiceMethodCallFromClient = 5800 | Wrong | Actual value is 151 (EMsg.js:46). The draft was off by orders of magnitude. |
EMsg.ServiceMethodResponse = 5801 | Wrong | Actual value is 147 (EMsg.js:42). |
EMsg.ServiceMethodCallFromClientNonAuthed = 9804 | Verified | EMsg.js:1946 |
EMsg.ClientToGC = 5452 | Verified | EMsg.js:1329 |
EMsg.ClientFromGC = 5453 | Verified | EMsg.js:1330 |
EMsg.ClientHello = 9805 (the CM-level hello — distinct from the GC ClientHello message) | Verified | EMsg.js:1947. Worth mentioning since the docs reference an EMsg.ClientHello send on 09-logon.js:408. |
5. Auth v2 / Unified Messages
| Claim | Status | Source / Correction |
|---|---|---|
UM target encoded as target_job_name = "Service.Method#Version" | Verified | CMAuthTransport.js:24; proto field at steammessages_base.proto:100 |
Job ID correlation via jobid_source/jobid_target | Verified | steammessages_base.proto:98-99, 03-messages.js:445-470 |
| Password is RSA PKCS#1 v1.5, NOT OAEP | Verified | Per Auth v2 Login Flow §B; this is distinct from the transport-layer OAEP-SHA1 |
GetPasswordRSAPublicKey returns publickey_mod, publickey_exp, timestamp | Verified | steammessages_auth.steamclient.proto:72-74 |
EAuthSessionGuardType values: 0=Unknown, 1=None, 2=EmailCode, 3=DeviceCode, 4=DeviceConfirmation, 5=EmailConfirmation, 6=MachineToken, 7=LegacyMachineAuth | Verified | steammessages_auth.steamclient.proto:21-28 |
PollAuthSessionStatus response’s refresh_token is the JWT used for CMsgClientLogon.access_token | Verified | 09-logon.js decoding logic, confirmed |
CMsgClientLogon.access_token = field 108 | Verified | steammessages_clientserver_login.proto:84 |
PROTOCOL_VERSION = 65580 in current node-steam-user | Verified | 09-logon.js:21 |
Response uses heartbeat_seconds, not out_of_game_heartbeat_seconds | Wrong | The draft used out_of_game_heartbeat_seconds. Proto has both fields (legacy legacy_out_of_game_heartbeat_seconds = 2 and current heartbeat_seconds = 3), and node-steam-user reads heartbeat_seconds (09-logon.js:813). Fix: refer to heartbeat_seconds. |
SteamKit’s Steam/Authentication/ has AuthSession, CredentialsAuthSession, SteamAuthentication, etc. | Verified | Directory listing confirms |
6. GC envelope (Layer 3)
| Claim | Status | Source / Correction |
|---|---|---|
CMsgGCClient { appid, msgtype, payload, ... } is the envelope | Verified | steammessages_clientserver_2.proto:200 |
EMsg.ClientToGC = 5452, EMsg.ClientFromGC = 5453 | Verified | (see §4) |
Proto-mask high bit on msgtype = 0x80000000 | Verified | gamecoordinator.js:11 (const PROTO_MASK = 0x80000000) |
7. CS2 GC bootstrap
| Claim | Status | Source / Correction |
|---|---|---|
k_EMsgGCClientHello = 4006 | Verified | gcsystemmsgs.proto:110 |
k_EMsgGCClientWelcome = 4004 | Verified | gcsystemmsgs.proto:108 |
k_EMsgGCCStrike15_v2_MatchmakingClient2GCHello = 9109 | Verified | cstrike15_gcmessages.proto:13 |
k_EMsgGCCStrike15_v2_MatchmakingGC2ClientHello = 9110 | Verified | cstrike15_gcmessages.proto:14 |
Must mount appid 730 via CMsgClientGamesPlayed before GC will respond | Verified | Per node-globaloffensive/index.js (state machine) — confirmed by code |
Outbound msgtype is ORed with 0x80000000 for protobuf-format messages | Verified | gamecoordinator.js:33 |
8. CS2 9110 response fields (MatchmakingGC2ClientHello)
| Claim | Status | Source / Correction |
|---|---|---|
player_level = field 17 (int32) | Verified | cstrike15_gcmessages.proto:648. Note: type is int32, not uint32. |
player_cur_xp = field 18 (int32) | Verified | cstrike15_gcmessages.proto:649. Note: int32. |
player_xp_bonus_flags = field 19 (int32) | Verified with caveat | Field exists at cstrike15_gcmessages.proto:650, but the node-globaloffensive README says: “player_xp_bonus_flags — Seems to always be null” (README lines 206 and 458). Original draft claimed “Prime status = bit 4 of player_xp_bonus_flags” — that claim is NOT supported by node-globaloffensive’s observations and should not be relied on without empirical testing on a CS2 account. |
rankings = field 20 (repeated PlayerRankingInfo) | Verified | cstrike15_gcmessages.proto:651 |
ranking (singular, legacy) = field 7 | Verified | cstrike15_gcmessages.proto:639 |
PlayerRankingInfo.rank_type_id is field 6 (uint32) | Verified | cstrike15_gcmessages.proto:244 |
PlayerRankingInfo.rank_id is field 2 (uint32) | Verified | cstrike15_gcmessages.proto:241 |
| rank_type_id meanings | Verified with caveat | The node-globaloffensive README (lines 185, 212, 437, 464) only documents 6 = Matchmaking, 7 = Wingman, 10 = Danger Zone. Premier (rank_type_id == 11) is NOT documented in node-globaloffensive nor enumerated in the proto. The proto leaves it as a plain uint32. The “11 = Premier” claim in the draft is community-knowledge from CS2 reverse-engineers but is not verifiable from this repo set. Treat as a hypothesis to confirm empirically. |
| Prime status mechanism | Wrong | Original draft: “bit 4 (value 0x10) of player_xp_bonus_flags”. This is unverifiable because (a) the README says that field is always null in practice, and (b) CSOEconGameAccountClient (SO cache type 7, which has elevated_state) is commented out in handlers.js:40-43 with the note “Account metadata - this doesn’t appear to be useful in CS:GO”. node-globaloffensive does not currently expose a Prime status field for CS2 via the GC at all. Empirical Prime detection has to come from another source (e.g. the Steam Web API IPlayerService.GetCSGOPrimeStatus if it still exists, or post-CS2 community knowledge). |
player_level semantics = CS2 competitive level / rank coin | Verified with caveat | The node-globaloffensive README labels this “Private rank level” (CSGO-era). In CS2 the same field carries the Profile Rank (1..40). The original framing “CS2 competitive matchmaking level / rank coin level” is reasonable but the README is conservative about it. |
player_cur_xp formula | Verified with caveat | The README documents level % = (player_cur_xp - 327680000) / 5000 — i.e. player_cur_xp is offset by 327680000 and each level is 5000 XP wide. The original draft just said “raw XP in current level”. Worth updating to include the offset and step. |
Premier rating is in rankings[] entry with rank_type_id==11, in the rank_id field | Verified with caveat | Same caveat as the rank_type_id meaning. The mechanism (read rank_id from the relevant entry of rankings[]) is structurally correct; the specific magic-number 11 is unverified. Additionally, CMsgGCCStrike15_v2_PremierSeasonSummary (EMsg 9224) exists as a separate message carrying Premier-season-summary data including per-week rank_id and per-map stats — useful to know that there’s a richer Premier endpoint, even though the simple “current Premier rating” likely does come from rankings[] as claimed. |
9. Heartbeat field name
| Claim | Status | Source / Correction |
|---|---|---|
Heartbeat interval from CMsgClientLogonResponse comes via out_of_game_heartbeat_seconds (typically 9s) | Wrong | The current proto has both legacy_out_of_game_heartbeat_seconds = 2 (legacy) and heartbeat_seconds = 3 (current). node-steam-user uses heartbeat_seconds (09-logon.js:813). The “9s” figure was a guess; the actual value is whatever the server returns. |
10. Summary of patches applied to the vault notes
The following corrections were applied to Steam Protocol Overview and CS2 Game Coordinator. Every other claim was verified as-is.
- EMsg quick-reference table: corrected
ClientGamesPlayed,ServiceMethodCallFromClient,ServiceMethodResponse. - Heartbeat reference (§6.2 Step E) updated from
out_of_game_heartbeat_secondstoheartbeat_seconds. - Prime claim (§8.3 and §8.4): downgraded from definitive (“bit 4 of
player_xp_bonus_flags”) to documented-as-open: “theplayer_xp_bonus_flagsfield is reported by node-globaloffensive as nearly always null; node-globaloffensive does not expose a Prime indicator; mechanism is an open empirical question for CS2”. - Premier rank_type_id == 11: marked as unverified; cross-referenced
CMsgGCCStrike15_v2_PremierSeasonSummary(EMsg 9224) as an additional Premier-related endpoint. player_cur_xpannotated with the README’s offset-and-step formula.player_level,player_cur_xp,player_xp_bonus_flagstyped asint32(proto-accurate) in tables.- Added pitfall #9 to “common pitfalls”: “Don’t blindly trust the rank_type_id values from CSGO-era documentation; verify against your own Premier account before shipping.”
- Added prominent caveat banner at the top of CS2 Game Coordinator flagging the two unverified claims (Prime via bit 4, Premier via rank_type_id 11).
What’s verified end-to-end vs. what still requires empirical confirmation
Verified, safe to ship:
- Everything in §1 (TCP transport), §2 (handshake), §3 (crypto). The transport layer is rock-solid against the repos.
- EMsg numbers (after the §4 corrections).
- Auth v2 message names, fields, and sequence.
CMsgClientLogonfield 108 =access_token. - GC envelope structure and the proto-mask bit.
- GC message IDs 4006/4004/9109/9110.
Verified structurally, but with semantics caveats:
player_levelandplayer_cur_xpdefinitely come from the 9110 response. The interpretation (CS2 level meaning, XP offset) follows the README but may shift across CS2 patches.rankings[]definitely carries per-rank-type ratings. The mappingrank_type_id == 11 → Premieris plausible but not in the repos.
Not verifiable from these repos:
- The “Prime via bit 4 of
player_xp_bonus_flags” claim. node-globaloffensive does not implement Prime detection. - The numeric
rank_type_idfor CS2 Premier.
For the two items above, recommend testing against a known-Prime, known-Premier-rating CS2 account and confirming the bytes match before committing them to any production code path.
Related
- hypelevels README
- Steam Protocol Overview — the audited document; corrections from this report are applied inline.
- CS2 Game Coordinator — the audited document; top-of-doc warning banner mirrors §8 of this report.
- TCP Transport and Handshake — verified clean by this audit, no corrections needed.
- Auth v2 Login Flow — verified clean by this audit, no corrections needed.