Steam Connection Manager + Game Coordinator Protocol Research
Goal of this document: Be precise enough that an engineer reading top-to-bottom can write a CM client from scratch — completing the TCP handshake, performing Auth v2 password login (including Steam Guard), and round-tripping a CS2 Game Coordinator hello to read player level, XP, Prime, and Premier rating.
Source repos cross-referenced (all live under /Volumes/bandi/coding/hypelevels/):
| Repo | Role |
|---|---|
SteamKit/ | C# reference impl (canonical, well-commented) |
node-steam-user/ | Node.js impl of CM transport + Auth v2 (current production code) |
node-globaloffensive/ | Node.js impl of the CS2 GC client layered on top of node-steam-user |
Protobufs/ | The SteamDatabase mirror of Valve’s .proto definitions |
This is a front door. The three companion notes contain the line-by-line citations:
- TCP Transport and Handshake — TCP frame, handshake, AES/RSA crypto
- Auth v2 Login Flow — Auth v2 Unified Messages, Steam Guard, JWT poll, CMsgClientLogon
- CS2 Game Coordinator — CMsgGCClient envelope, CS2 bootstrap, MatchmakingClient2GCHello and the 9110 response
For the per-claim audit trail (what was verified, what was wrong, what is still empirically unconfirmed), see Validation Report.
If a claim here is missing a citation, the corresponding section note has it.
1. Layered model
┌─────────────────────────────────────────────────────────────┐
│ Layer 4: Application protocols │
│ • CS2 GC messages (CMsgGCCStrike15_v2_*) │
│ • Other GC-specific protocols (Dota 2, TF2, Deadlock…) │
├─────────────────────────────────────────────────────────────┤
│ Layer 3: GC envelope │
│ • CMsgGCClient { app_id, msgtype, payload } │
│ • Carried over EMsg.ClientToGC / EMsg.ClientFromGC │
├─────────────────────────────────────────────────────────────┤
│ Layer 2b: Unified Messages (Auth.* during login, │
│ then anything else after) │
│ • EMsg.ServiceMethodCallFromClient[NonAuthed] │
│ • target_job_name = "Service.Method#Version" │
├─────────────────────────────────────────────────────────────┤
│ Layer 2a: CM messages (EMsg-keyed, MsgHdr or MsgHdrProtoBuf)│
│ • CMsgClientLogon, CMsgClientHeartBeat, │
│ CMsgClientGamesPlayed, … │
├─────────────────────────────────────────────────────────────┤
│ Layer 1b: AES-256-CBC bulk encryption (after handshake) │
│ • IV derived per-message via HMAC-SHA1 │
├─────────────────────────────────────────────────────────────┤
│ Layer 1a: TCP frame │
│ • 4-byte LE length + "VT01" magic + payload │
├─────────────────────────────────────────────────────────────┤
│ Layer 0: TCP socket to a CM server (port 27017/27018/…) │
└─────────────────────────────────────────────────────────────┘
CMs are discovered via the public Steam Web API (ISteamDirectory.GetCMListForConnect) or hard-coded fallbacks. That is orthogonal to the bytes-on-the-wire concerns below — pick any CM TCP endpoint and the rest of this doc applies.
2. The TCP frame (Layer 1a) — the only thing that is always plaintext
Every CM message — including the unencrypted handshake AND every encrypted message after — is wrapped in an 8-byte plaintext header:
offset 0 ┌───────────────────────────────────┐
│ payload_length : uint32 little-endian│ bytes 0..3
offset 4 ├───────────────────────────────────┤
│ magic = "VT01" (0x31305456 LE) │ bytes 4..7
offset 8 ├───────────────────────────────────┤
│ payload (payload_length bytes) │
└───────────────────────────────────┘
- The magic value is the ASCII string
"VT01". In code it is checked as the little-endianuint32 0x31305456. - The header itself is never encrypted. After the handshake, the bytes in
payloadare AES-encrypted, but the 8-byte frame is always cleartext. - On magic mismatch, both SteamKit and node-steam-user tear the connection down with no recovery.
Receive state machine (both implementations):
- Read 8 bytes → extract
length(LE u32 from offset 0), validate magic. - Read
lengthbytes → that is the “packet body” (either a plaintext handshake message or an encrypted CM message). - Pass the body to the next layer (decryption or direct handshake parser).
Citations: TcpConnection.cs:282-302, tcp.js:186-200. Full detail and code excerpts in TCP Transport and Handshake §A.
3. The initial handshake (Layer 1b setup) — three messages, plaintext, MsgHdr (not protobuf) header
Right after the TCP socket connects, the server speaks first. The flow is exactly three messages, all using the classic 20-byte MsgHdr (EMsg uint32 + targetJobID uint64 + sourceJobID uint64). They are not protobuf-wrapped.
3.1 Server → Client: ChannelEncryptRequest (EMsg 1303 / 0x050B)
Body:
| Field | Type | Notes |
|---|---|---|
| ProtocolVersion | u32 LE | always 1 |
| Universe | u32 LE | 1=Public, 2=Beta, 3=Internal, 4=Dev — selects which RSA pubkey to use |
| RandomChallenge | bytes (16+) | server-chosen nonce, must be echoed inside the RSA blob |
3.2 Client → Server: ChannelEncryptResponse (EMsg 1304 / 0x050C)
Body:
| Field | Type | Notes |
|---|---|---|
| ProtocolVersion | u32 LE | 1 |
| KeySize | u32 LE | 128 (size of the RSA-1024 ciphertext) |
| EncryptedKey | 128 bytes | RSA-OAEP-SHA1 of (sessionKey ‖ randomChallenge) |
| KeyCrc | u32 LE | CRC32 over EncryptedKey |
| Reserved | u32 LE | always 0 |
The 32-byte AES session key is generated client-side with a CSPRNG (RandomNumberGenerator.GetBytes(32) in SteamKit, equivalent via @doctormckay/steam-crypto in node-steam-user). The plaintext blob fed to RSA is the literal concatenation sessionKey(32) ‖ randomChallenge(16+). The CRC32 is computed over the encrypted (post-RSA) bytes, not the plaintext.
Padding scheme is RSA-OAEP with SHA-1 for both the OAEP hash and the MGF1 hash. This is the .NET constant RSAEncryptionPadding.OaepSHA1. (Do not confuse this with the per-account password encryption later in §6 — that one is PKCS#1 v1.5.)
The RSA public keys for each universe are baked-in constants. The modulus is 1024 bits (128 bytes); the public exponent is 0x11 (decimal 17) for every universe (KeyDictionary.cs).
3.3 Server → Client: ChannelEncryptResult (EMsg 1305 / 0x050D)
Body:
| Field | Type | Notes |
|---|---|---|
| Result | i32 LE | EResult enum; must be 1 (OK) |
Hard requirement: the client must verify Result == 1 before flipping the connection into encrypted mode. Until that flip, the connection is still plaintext — including any messages the client might have queued behind ChannelEncryptResponse. SteamKit models this as a state machine with states Connected → Challenged → Encrypted (EnvelopeEncryptedConnection.cs); node-steam-user does it by gating on this._connection.sessionKey being set (02-connection.js:137).
Full detail and excerpts: TCP Transport and Handshake §B.
4. AES-256-CBC bulk encryption with HMAC-SHA1 IV (Layer 1b at runtime)
After the handshake succeeds, every CM message’s bytes (i.e. the payload slot inside the 8-byte frame) are AES-encrypted. The mode is not vanilla CBC — IVs are derived per-message via HMAC-SHA1 so that an attacker observing two messages cannot infer plaintext relationships from IV reuse.
Key split
The 32-byte session key is used two ways:
| Bytes | Used as |
|---|---|
sessionKey[0..16] (first 16 bytes) | HMAC-SHA1 key (hmacSecret) |
sessionKey[0..32] (all 32 bytes) | AES-256 key |
HMAC key is the first 16 bytes
This is a common point of confusion. The HMAC key is the first 16 bytes of the session key (
NetFilterEncryptionWithHMAC.cs:32-33), not the last 16. Get this wrong and decryption silently produces garbage that looks plausible.
Per-message IV construction (send path)
random_3 = 3 random bytes
hmac_input = random_3 ‖ plaintext
hmac_output = HMAC-SHA1(key=hmacSecret, msg=hmac_input) # 20 bytes
iv = hmac_output[0..13] ‖ random_3 # 16 bytes
Wire layout of an encrypted CM message
encrypted_iv = AES-256-ECB(key=sessionKey, plaintext=iv, padding=None) # 16 bytes
encrypted_body = AES-256-CBC(key=sessionKey, iv=iv, plaintext=body, padding=PKCS7)
payload = encrypted_iv ‖ encrypted_body
That payload then gets the 8-byte VT01 TCP frame slapped in front of it.
Receive path
- Read TCP frame, get
payload. encrypted_iv = payload[0..16];encrypted_body = payload[16..].iv = AES-256-ECB-Decrypt(sessionKey, encrypted_iv, padding=None).body = AES-256-CBC-Decrypt(sessionKey, iv, encrypted_body, padding=PKCS7).- Validate: recompute the IV from
body(the random_3 portion is at iv[13..16]) and check it matches the received IV. Tamper detection.
Citations: NetFilterEncryptionWithHMAC.cs:24-118. Both SteamKit and node-steam-user use this same mode; there is no legacy fallback in either current codebase.
5. CM messages — two header formats coexist
Once the channel is encrypted, every message is an EMsg-keyed CM message. There are two header formats; which one is used is decided per-message by a bit in the EMsg value itself:
MsgHdr(20 bytes) — classic, for non-protobuf messages. Used by the handshake messages (ChannelEncrypt*) and a few legacy paths.MsgHdrProtoBuf— for protobuf-bodied messages (essentially everything modern). Layout:EMsg (u32 LE) ‖ length(headerProto) (u32 LE) ‖ headerProto (CMsgProtoBufHeader) ‖ body (protobuf). The EMsg has its high bit (0x80000000) set to signal “protobuf message”.
The CMsgProtoBufHeader (steammessages_base.proto) carries the things that don’t fit in MsgHdr — steamid, client_sessionid, jobid_source, jobid_target, target_job_name (for Unified Messages), eresult, etc.
When SteamKit/node-steam-user receive a packet they look at EMsg & 0x80000000 first to decide which header parser to use (CMClient.cs:493-531).
6. Auth v2 login flow (Layer 2b → Layer 2a)
The big idea of Auth v2: the actual CMsgClientLogon is the LAST step, and it carries a JWT obtained from a side-channel Authentication.* Unified Message conversation. The password is exchanged for the JWT; Steam Guard happens during that exchange.
6.1 Unified Message wire format
A “Unified Message” is a regular CM message whose payload is a protobuf, addressed via a string “Service.Method#Version” instead of a numeric EMsg. They ride inside one of two EMsg values:
EMsg.ServiceMethodCallFromClientNonAuthed— used before login, when the client has no SteamID yet.EMsg.ServiceMethodCallFromClient— after login.
node-steam-user picks one based on whether _user.steamID is set (CMAuthTransport.js:22-23):
msg: this._user.steamID ? EMsg.ServiceMethodCallFromClient : EMsg.ServiceMethodCallFromClientNonAuthed,The target method goes in the header’s target_job_name field as the literal string "Authentication.BeginAuthSessionViaCredentials#1" (etc.). Responses correlate via jobid_source (client-minted, monotonically incrementing) → jobid_target (echoed by server). See Auth v2 Login Flow §A for citations.
6.2 Step-by-step login sequence
Precondition: §3 + §4 are complete — the channel is AES-encrypted.
Step A — Authentication.GetPasswordRSAPublicKey#1
Request: { account_name }.
Response: { publickey_mod (hex string), publickey_exp (hex string), timestamp (u64) }.
Password RSA = PKCS#1 v1.5, NOT OAEP
This pubkey is per-account, rotates, and is used only to encrypt the password. It is NOT the transport RSA key. The padding for this RSA op is PKCS#1 v1.5, not OAEP — easy to get wrong because the transport handshake uses OAEP-SHA1. The output is base64-encoded and used as the
encrypted_passwordfield in the next step. Thetimestampfrom the response must be echoed back asencryption_timestamp.
Step B — Authentication.BeginAuthSessionViaCredentials#1
Request (fields actually populated by the client):
account_name : string
encrypted_password : string (base64 of RSA-PKCS1v1.5(rsa_pubkey, password))
encryption_timestamp : u64 (echoed from Step A)
persistence : enum (k_ESessionPersistence_Persistent = 1)
website_id : string ("Client" for native client login)
device_details : { device_friendly_name, platform_type, os_type }
Response:
client_id : u64
request_id : bytes
interval : float (polling interval in seconds)
allowed_confirmations : repeated { confirmation_type, associated_message }
steamid : fixed64
weak_token : string (short-lived; not used for CMsgClientLogon)
allowed_confirmations[].confirmation_type is an enum. The values worth knowing:
| Value | Meaning |
|---|---|
| 1 | k_EAuthSessionGuardType_None — no Steam Guard, login can finish immediately on poll |
| 2 | k_EAuthSessionGuardType_EmailCode |
| 3 | k_EAuthSessionGuardType_DeviceCode (TOTP from the Steam mobile app) |
| 4 | k_EAuthSessionGuardType_DeviceConfirmation (push-to-approve on the mobile app — nothing to submit, just poll) |
| 5 | k_EAuthSessionGuardType_EmailConfirmation |
The client picks the first method it can satisfy. If the list contains DeviceConfirmation only, the user approves on their phone and the client just polls — no code submission needed. If it contains EmailCode/DeviceCode, the client must collect the code from the user before polling will succeed.
Step C (conditional) — Authentication.UpdateAuthSessionWithSteamGuardCode#1
Only sent when allowed_confirmations contains EmailCode or DeviceCode.
Request:
client_id : u64 (from Step B)
steamid : fixed64 (from Step B)
code : string (5 chars for EmailCode, 5 chars TOTP for DeviceCode)
code_type : enum (matching what was in allowed_confirmations)
Response: empty on success (errors surface via eresult in the protobuf header).
Sequencing note (worth getting right): node-steam-user submits the code before it starts polling (09-logon.js:454-459). It does not wait for a poll to fail and then prompt — the user must have provided the code up-front (via the twoFactorCode / authCode option on logOn()). If the code is wrong, the next poll returns an error and the flow restarts.
Validated
EAuthSessionGuardTypeenum values 0..7 confirmed atsteammessages_auth.steamclient.proto:21-28.
Step D — Authentication.PollAuthSessionStatus#1 (loop)
Request:
client_id : u64
request_id : bytes
Response:
new_client_id : u64 (clients should swap to this on receipt; rotation defense)
new_challenge_url : string (QR rotation — not relevant to password flow)
refresh_token : string (← the JWT, the prize, the thing we're after)
access_token : string (short-lived; not the one used for logon)
had_remote_interaction : bool
account_name : string
Poll cadence: respect the interval from Step B (it’s a float in seconds; node-steam-user converts to ms in 09-logon.js). Termination: when refresh_token is a non-empty string, login is “approved” — stop polling and proceed to Step E. If the proto header’s eresult is anything other than OK, abort.
refresh_tokenvsaccess_token— name trapThe long-lived JWT used in
CMsgClientLogonis whatPollAuthSessionStatuscallsrefresh_token. It is not the response’saccess_token(which is a short-lived one). When you wire this through, do not let the variable name in your code drift.
Step E — CMsgClientLogon (EMsg 5514)
The classical login message, now with the JWT in access_token:
account_name : string
access_token : string ← the JWT from refresh_token field of PollAuthSessionStatus
protocol_version : u32 (current value: 65580)
client_os_type : u32 (from EOSType enum)
client_language : string ("english", etc.)
machine_id : bytes (binary-V proprietary structure; node-steam-user has a helper)
cell_id : u32 (region hint)
client_instance_id : u32 (usually 0)
supports_rate_limit_response : bool
chat_mode : u32 (2 = new chat system)
obfuscated_private_ip : protobuf message with v4 ⊕ MAGIC
machine_name : string
The message header’s steamid field at this point is an “anonymous user” SteamID (the account-type=AnonUser, instance=0 SteamID), because the client doesn’t yet have a session. The server’s response (CMsgClientLogonResponse, EMsg 751) provides the real SteamID, the session id, and the heartbeat interval (heartbeat_seconds, field 3 — note: there is also a legacy legacy_out_of_game_heartbeat_seconds field 2 which modern clients ignore; node-steam-user uses heartbeat_seconds, see 09-logon.js:813). At that point the client starts sending CMsgClientHeartBeat (EMsg 703) every heartbeat_seconds seconds.
Validated
access_tokenis field 108 onCMsgClientLogon(steammessages_clientserver_login.proto:84).PROTOCOL_VERSION = 65580in current node-steam-user (09-logon.js:21).
Full detail and field-level citations: Auth v2 Login Flow §B–§F.
6.3 Login state machine (textual)
Disconnected
│ TCP connect + handshake §3 + §4
▼
ChannelEncrypted (no session)
│ ServiceMethodCallFromClientNonAuthed: GetPasswordRSAPublicKey
▼
HavePubkey
│ ServiceMethodCallFromClientNonAuthed: BeginAuthSessionViaCredentials
▼
AwaitingConfirmation
│ if EmailCode/DeviceCode required:
│ ServiceMethodCallFromClientNonAuthed: UpdateAuthSessionWithSteamGuardCode
│ if DeviceConfirmation only: skip
▼
Polling
│ loop ServiceMethodCallFromClientNonAuthed: PollAuthSessionStatus
│ until refresh_token populated
▼
HaveJWT
│ send CMsgClientLogon { access_token=refresh_token, ... }
│ receive CMsgClientLogonResponse(EResult=OK, steamid, session_id, heartbeat_sec)
▼
LoggedOn (heartbeat every heartbeat_sec)
7. Game Coordinator envelope (Layer 3)
Once logged in, talking to a Game Coordinator (Dota 2 GC, CS2 GC, TF2 GC, etc.) is just a wrapping pattern:
7.1 CMsgGCClient envelope
Proto (steammessages_clientserver_2.proto):
message CMsgGCClient {
optional uint32 appid = 1; // 730 for CS2
optional uint32 msgtype = 2; // GC-internal message id, see §7.3
optional bytes payload = 3; // serialized GC message: gc_header ‖ gc_body
optional fixed64 steamid = 4;
optional string gcname = 5;
optional uint32 ip = 6;
optional uint32 flags = 7;
}This envelope rides inside ordinary CM messages keyed by:
| EMsg | Value | Direction |
|---|---|---|
EMsg.ClientToGC | 5452 | client → GC |
EMsg.ClientFromGC | 5453 | GC → client |
The CM-level CMsgProtoBufHeader.routing_appid is also set to 730 to help routing.
7.2 The payload is itself a layered thing
Inside CMsgGCClient.payload, the structure mirrors §5: there is a GC protobuf header followed by the GC body. The GC header is a separate protobuf type — CMsgProtoBufHeader defined in gcsdk_gcmessages.proto (different from the CM one in steammessages_base.proto, despite the same name) — containing client_steam_id, client_session_id, source_app_id, target_job_name, jobid_source, jobid_target. The four-byte length prefix in front of the GC header is the same pattern as on the CM side.
7.3 msgtype and the proto-mask high bit
Proto-mask high bit on
msgtypeThe high bit
0x80000000onmsgtypesignals “GC body is a protobuf” (vs the legacy struct GC messages used by some older games). For CS2, everything is protobuf, so every outbound msgtype gets ORed with0x80000000(gamecoordinator.js:11,33,62). On the receiving side, the bit is masked off to read the raw numeric message id.
For example:
- request msgtype on the wire:
9109 | 0x80000000 = 0x80002395 - raw message id when matching against
language.js:9109
8. Connecting to the CS2 Game Coordinator and reading the player record
This is the concrete recipe.
8.1 Bootstrap — “mount” appid 730
The GC will not respond to anything until the Steam servers believe the user is currently playing the game. Send a CMsgClientGamesPlayed (EMsg 742) listing app 730:
games_played: [
{
game_id: 730 // u64 — for non-mod games this is just the appid
// other GamePlayed fields can stay defaulted
}
]
Then wait briefly for Steam to acknowledge by routing the next outbound GC message to the correct GC. node-globaloffensive uses a state machine that sends a ClientHello repeatedly until the GC replies with ClientWelcome — i.e. it treats the hello as the bootstrap probe.
8.2 The two-hello dance
There are two “hello” messages and you should know the difference:
| Message | id (raw) | id (with proto-mask) | Purpose |
|---|---|---|---|
k_EMsgGCClientHello (base, from gcsdk_gcmessages.proto) | 4006 | 0x80000FA6 | GCSDK-level “are you there” |
k_EMsgGCCStrike15_v2_MatchmakingClient2GCHello (CS2-specific) | 9109 | 0x80002395 | ”Tell me my CS2 account state” |
node-globaloffensive sends the base ClientHello first (gets ClientWelcome = 4004 back, confirming the GC is up), then the CS2-specific MatchmakingClient2GCHello. The latter is what triggers the response we care about.
The CS2 hello request is intentionally empty — CMsgGCCStrike15_v2_MatchmakingClient2GCHello has no required fields. You send an empty protobuf body. Reference: cstrike15_gcmessages.proto.
8.3 The 9110 response — CMsgGCCStrike15_v2_MatchmakingGC2ClientHello
The interesting fields (from cstrike15_gcmessages.proto:632-672):
account_id : uint32
player_level : int32 (field 17) // node-globaloffensive README labels this "private rank level"; in CS2 it is the Profile Rank (1..40 range)
player_cur_xp : int32 (field 18) // see formula below
player_xp_bonus_flags : int32 (field 19) // see caveat below — frequently null in practice
rankings : repeated PlayerRankingInfo (field 20)
ranking : PlayerRankingInfo (field 7) // legacy CSGO competitive only
commendation : AccountCommendation
medals : PlayerMedals
vac_banned : uint32
PlayerRankingInfo (cstrike15_gcmessages.proto) has these notable fields:
account_id : uint32 (field 1)
rank_id : uint32 (field 2) // for the matched rank_type_id, this is the rating/rank value
wins : uint32 (field 3)
rank_change : float (field 4)
rank_type_id : uint32 (field 6)
per_map_rank : repeated PerMapRank (field 13)
leaderboard_name : string (field 9)
player_cur_xp formula
From the node-globaloffensive README (lines 205, 457): player_cur_xp is offset by 327680000 and each level is 5000 XP wide:
levelPercent = (player_cur_xp - 327680000) / 5000 // 0..100 within current level
So a raw player_cur_xp of 327680000 = 0% into the current level, 327685000 = 100% (i.e. about to level up).
rank_type_id values
node-globaloffensive’s README (lines 185, 212, 437, 464) documents only:
| Value | Meaning |
|---|---|
| 6 | Matchmaking (legacy CS:GO competitive) |
| 7 | Wingman |
| 10 | Danger Zone |
Premier (rank_type_id) is NOT in the repos
The proto enumeration is open (just a
uint32), and node-globaloffensive’s README pre-dates CS2’s Premier mode. The community-asserted valuerank_type_id == 11for CS2 Premier is not verifiable from these repos. Confirm empirically against a known-Premier CS2 account before committing the magic number. Structurally the mechanism is correct: walkrankings[], match onrank_type_id, readrank_idas the rating.
There is also CMsgGCCStrike15_v2_PremierSeasonSummary (EMsg 9224, cstrike15_gcmessages.proto:837-864) — a separate request that returns per-week rank_id history plus per-map win/loss/kill stats. Useful as a richer Premier data source than just the 9110 hello.
Player level
Read player_level directly. Per the README it has historically meant “private rank level” (CSGO-era 1..40 profile rank). In CS2 the field carries the same Profile Rank value.
Prime status
The "Prime = bit 4 of
player_xp_bonus_flags" mechanism is unverifiednode-globaloffensive’s README explicitly says
player_xp_bonus_flags“Seems to always benull” (README lines 206, 458). The alternative path — readingCSOEconGameAccountClient.elevated_statefrom SO cache type 7 — is commented out inhandlers.js:40-43with the note “Account metadata - this doesn’t appear to be useful in CS:GO”.node-globaloffensive does not currently expose a Prime indicator for CS2 via the GC at all. If you need Prime status, you have to either: (a) test bit 4 of
player_xp_bonus_flagsempirically and accept it may be null, (b) decode the SO cache yourself and inspectelevated_state, or (c) source Prime from outside the GC (e.g. Steam Web API).
8.4 End-to-end byte sequence
Putting it all together:
1. TCP connect to a CM endpoint (e.g. resolve via ISteamDirectory).
2. Recv ChannelEncryptRequest(universe, nonce). [§3.1]
3. Send ChannelEncryptResponse(RSA-OAEP-SHA1(pubkey[universe], sessionKey ‖ nonce), CRC32, 0). [§3.2]
4. Recv ChannelEncryptResult; verify EResult.OK. Flip to encrypted mode. [§3.3 / §4]
5. UM (EMsg.ServiceMethodCallFromClientNonAuthed=9804):
Authentication.GetPasswordRSAPublicKey#1 → mod, exp, timestamp.
6. RSA-PKCS1v1.5 encrypt password with (mod, exp). Base64.
7. UM: Authentication.BeginAuthSessionViaCredentials#1 → client_id, request_id, interval, allowed_confirmations, steamid.
8. (if EmailCode/DeviceCode) UM: Authentication.UpdateAuthSessionWithSteamGuardCode#1.
9. Loop UM: Authentication.PollAuthSessionStatus#1 every `interval` seconds until response's refresh_token is non-empty.
10. Send CMsgClientLogon (EMsg 5514) { access_token=refresh_token, account_name, machine_id, protocol_version=65580, ... }.
11. Recv CMsgClientLogonResponse (EMsg 751) (EResult.OK, steamid, heartbeat_seconds).
12. Start CMsgClientHeartBeat (EMsg 703) timer every heartbeat_seconds.
13. Send CMsgClientGamesPlayed (EMsg 742) { games_played: [{ game_id: 730 }] }.
14. Send CMsgGCClient (EMsg.ClientToGC=5452) { appid=730, msgtype=4006|0x80000000, payload=gc_header‖CMsgClientHello{} }.
15. Recv CMsgGCClient (EMsg.ClientFromGC=5453) { msgtype=4004|0x80000000, ... } → ClientWelcome. GC is alive.
16. Send CMsgGCClient { appid=730, msgtype=9109|0x80000000, payload=gc_header‖CMsgGCCStrike15_v2_MatchmakingClient2GCHello{} }.
17. Recv CMsgGCClient { msgtype=9110|0x80000000, ... }.
18. Decode payload: strip the 4-byte GC-header-length, parse CMsgProtoBufHeader(gc), then parse CMsgGCCStrike15_v2_MatchmakingGC2ClientHello.
19. Read fields from the response:
• player_level (field 17, int32) — directly usable as level
• player_cur_xp (field 18, int32) — apply README formula: levelPercent = (player_cur_xp - 327680000) / 5000
• rankings[] (field 20) — iterate; for each entry inspect rank_type_id and rank_id.
Premier rating is *suspected* to be the entry with rank_type_id == 11
(NOT confirmed by these repos — verify empirically).
• Prime status — NOT reliably exposed by node-globaloffensive. player_xp_bonus_flags (field 19) is
frequently null per README. CSOEconGameAccountClient.elevated_state (SO cache type 7)
is the structurally-correct path but node-globaloffensive's code for it is commented
out. Recommend confirming the mechanism experimentally before relying on it.
20. Done.
Full detail and citations: CS2 Game Coordinator.
9. Constants quick reference
| Item | Value |
|---|---|
| TCP frame magic | "VT01" = 0x31305456 LE |
EMsg.ChannelEncryptRequest | 1303 |
EMsg.ChannelEncryptResponse | 1304 |
EMsg.ChannelEncryptResult | 1305 |
EMsg.ClientLogon | 5514 |
EMsg.ClientLogOnResponse (note capital On) | 751 |
EMsg.ClientHeartBeat | 703 |
EMsg.ClientGamesPlayed | 742 (corrected) |
EMsg.ServiceMethodCallFromClient | 151 (corrected) |
EMsg.ServiceMethodCallFromClientNonAuthed | 9804 |
EMsg.ServiceMethodResponse | 147 (corrected) |
EMsg.ClientHello (CM-level) | 9805 |
EMsg.ClientToGC | 5452 |
EMsg.ClientFromGC | 5453 |
| Transport RSA padding | OAEP-SHA1, 1024-bit modulus, exponent 0x11 |
| Password RSA padding | PKCS#1 v1.5 (different layer!) |
| AES key size | 256 bits, key = sessionKey[0..32] |
| HMAC-SHA1 key | sessionKey[0..16] (first 16 bytes!) |
| IV length | 16 bytes (= 13 bytes HMAC-SHA1 ‖ 3 bytes random) |
| AES mode | CBC, PKCS7 padding |
| IV-of-IV mode | ECB, no padding |
| GC proto-mask high bit | 0x80000000 |
| CS2 appid | 730 |
k_EMsgGCClientHello | 4006 |
k_EMsgGCClientWelcome | 4004 |
k_EMsgGCCStrike15_v2_MatchmakingClient2GCHello | 9109 |
k_EMsgGCCStrike15_v2_MatchmakingGC2ClientHello | 9110 |
Prime bit in player_xp_bonus_flags | bit 4 (value 0x10) — unverified, see §8.3 |
rank_type_id for Premier | 11 — unverified, see §8.3 |
10. Common pitfalls
- Mixing up the two RSA operations. Transport handshake = OAEP-SHA1. Password encryption = PKCS#1 v1.5. They live at different layers, use different keys (the transport pubkey is universe-static; the password pubkey is per-account and rotates).
- HMAC key from the wrong half of the session key. It’s the first 16 bytes. SteamKit, node-steam-user, and steam-crypto all agree.
- Flipping into encrypted mode after sending ChannelEncryptResponse but before validating ChannelEncryptResult. Don’t. The server might still reject. Wait for
EResult.OK. - Sending the CS2 hello before
CMsgClientGamesPlayed. The GC will not answer. The Steam server uses GamesPlayed to route GC messages, and without it your envelopes go nowhere. - Forgetting the proto-mask on
msgtype. Outbound CS2 messages must OR with0x80000000. Inbound messages have the bit set too; mask it off before matching against language.js IDs. - Conflating
refresh_tokenandaccess_tokenin PollAuthSessionStatus. The thing that goes intoCMsgClientLogon.access_tokenis the response’srefresh_tokenfield. The response’s ownaccess_tokenfield is a short-lived web token, NOT what you want. - Reading
ranking(singular) instead ofrankings[]for Premier. The singularrankingis the legacy CS:GO competitive rank only. Premier and the rest are in the repeatedrankings[]. - Heartbeat starvation. Steam disconnects sessions that miss heartbeats — start the timer immediately after
CMsgClientLogonResponsewith theheartbeat_secondsinterval the server returned. (Don’t readlegacy_out_of_game_heartbeat_seconds— that’s the legacy field, modern clients ignore it.) - CSGO-era documentation drift on CS2 semantics. node-globaloffensive’s README documents
rank_type_idvalues for CSGO only (6=Matchmaking, 7=Wingman, 10=Danger Zone). Premier (CS2-only) is not in the repo set. Don’t shiprank_type_id == 11 → Premierwithout confirming the byte value against a known-Premier account. Same goes for Prime status —player_xp_bonus_flagsis documented as “seems always null”, andCSOEconGameAccountClientdecoding is commented out in node-globaloffensive. These are open empirical questions, not validated facts. See Validation Report for the full audit trail.
Related
- hypelevels README
- Validation Report — per-claim audit trail; lists every corrected EMsg value and the two GC claims that remain unverified.
- TCP Transport and Handshake — TCP frame, handshake, AES/RSA — 812 lines, full line-anchored citations.
- Auth v2 Login Flow — Auth v2 UM flow + Steam Guard + JWT + CMsgClientLogon — 737 lines.
- CS2 Game Coordinator — CMsgGCClient envelope + CS2 GC bootstrap + 9110 parsing — 770 lines.