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/):

RepoRole
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:

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-endian uint32 0x31305456.
  • The header itself is never encrypted. After the handshake, the bytes in payload are 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):

  1. Read 8 bytes → extract length (LE u32 from offset 0), validate magic.
  2. Read length bytes → that is the “packet body” (either a plaintext handshake message or an encrypted CM message).
  3. 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:

FieldTypeNotes
ProtocolVersionu32 LEalways 1
Universeu32 LE1=Public, 2=Beta, 3=Internal, 4=Dev — selects which RSA pubkey to use
RandomChallengebytes (16+)server-chosen nonce, must be echoed inside the RSA blob

3.2 Client → Server: ChannelEncryptResponse (EMsg 1304 / 0x050C)

Body:

FieldTypeNotes
ProtocolVersionu32 LE1
KeySizeu32 LE128 (size of the RSA-1024 ciphertext)
EncryptedKey128 bytesRSA-OAEP-SHA1 of (sessionKey ‖ randomChallenge)
KeyCrcu32 LECRC32 over EncryptedKey
Reservedu32 LEalways 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:

FieldTypeNotes
Resulti32 LEEResult 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:

BytesUsed 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

  1. Read TCP frame, get payload.
  2. encrypted_iv = payload[0..16]; encrypted_body = payload[16..].
  3. iv = AES-256-ECB-Decrypt(sessionKey, encrypted_iv, padding=None).
  4. body = AES-256-CBC-Decrypt(sessionKey, iv, encrypted_body, padding=PKCS7).
  5. 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 MsgHdrsteamid, 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_password field in the next step. The timestamp from the response must be echoed back as encryption_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:

ValueMeaning
1k_EAuthSessionGuardType_None — no Steam Guard, login can finish immediately on poll
2k_EAuthSessionGuardType_EmailCode
3k_EAuthSessionGuardType_DeviceCode (TOTP from the Steam mobile app)
4k_EAuthSessionGuardType_DeviceConfirmation (push-to-approve on the mobile app — nothing to submit, just poll)
5k_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

EAuthSessionGuardType enum values 0..7 confirmed at steammessages_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_token vs access_token — name trap

The long-lived JWT used in CMsgClientLogon is what PollAuthSessionStatus calls refresh_token. It is not the response’s access_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_token is field 108 on CMsgClientLogon (steammessages_clientserver_login.proto:84). PROTOCOL_VERSION = 65580 in 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:

EMsgValueDirection
EMsg.ClientToGC5452client → GC
EMsg.ClientFromGC5453GC → 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 msgtype

The high bit 0x80000000 on msgtype signals “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 with 0x80000000 (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:

Messageid (raw)id (with proto-mask)Purpose
k_EMsgGCClientHello (base, from gcsdk_gcmessages.proto)40060x80000FA6GCSDK-level “are you there”
k_EMsgGCCStrike15_v2_MatchmakingClient2GCHello (CS2-specific)91090x80002395”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 emptyCMsgGCCStrike15_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:

ValueMeaning
6Matchmaking (legacy CS:GO competitive)
7Wingman
10Danger 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 value rank_type_id == 11 for 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: walk rankings[], match on rank_type_id, read rank_id as 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 unverified

node-globaloffensive’s README explicitly says player_xp_bonus_flags “Seems to always be null (README lines 206, 458). The alternative path — reading CSOEconGameAccountClient.elevated_state from SO cache type 7 — 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 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_flags empirically and accept it may be null, (b) decode the SO cache yourself and inspect elevated_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

ItemValue
TCP frame magic"VT01" = 0x31305456 LE
EMsg.ChannelEncryptRequest1303
EMsg.ChannelEncryptResponse1304
EMsg.ChannelEncryptResult1305
EMsg.ClientLogon5514
EMsg.ClientLogOnResponse (note capital On)751
EMsg.ClientHeartBeat703
EMsg.ClientGamesPlayed742 (corrected)
EMsg.ServiceMethodCallFromClient151 (corrected)
EMsg.ServiceMethodCallFromClientNonAuthed9804
EMsg.ServiceMethodResponse147 (corrected)
EMsg.ClientHello (CM-level)9805
EMsg.ClientToGC5452
EMsg.ClientFromGC5453
Transport RSA paddingOAEP-SHA1, 1024-bit modulus, exponent 0x11
Password RSA paddingPKCS#1 v1.5 (different layer!)
AES key size256 bits, key = sessionKey[0..32]
HMAC-SHA1 keysessionKey[0..16] (first 16 bytes!)
IV length16 bytes (= 13 bytes HMAC-SHA1 ‖ 3 bytes random)
AES modeCBC, PKCS7 padding
IV-of-IV modeECB, no padding
GC proto-mask high bit0x80000000
CS2 appid730
k_EMsgGCClientHello4006
k_EMsgGCClientWelcome4004
k_EMsgGCCStrike15_v2_MatchmakingClient2GCHello9109
k_EMsgGCCStrike15_v2_MatchmakingGC2ClientHello9110
Prime bit in player_xp_bonus_flagsbit 4 (value 0x10) — unverified, see §8.3
rank_type_id for Premier11 — unverified, see §8.3

10. Common pitfalls

  1. 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).
  2. 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.
  3. Flipping into encrypted mode after sending ChannelEncryptResponse but before validating ChannelEncryptResult. Don’t. The server might still reject. Wait for EResult.OK.
  4. 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.
  5. Forgetting the proto-mask on msgtype. Outbound CS2 messages must OR with 0x80000000. Inbound messages have the bit set too; mask it off before matching against language.js IDs.
  6. Conflating refresh_token and access_token in PollAuthSessionStatus. The thing that goes into CMsgClientLogon.access_token is the response’s refresh_token field. The response’s own access_token field is a short-lived web token, NOT what you want.
  7. Reading ranking (singular) instead of rankings[] for Premier. The singular ranking is the legacy CS:GO competitive rank only. Premier and the rest are in the repeated rankings[].
  8. Heartbeat starvation. Steam disconnects sessions that miss heartbeats — start the timer immediately after CMsgClientLogonResponse with the heartbeat_seconds interval the server returned. (Don’t read legacy_out_of_game_heartbeat_seconds — that’s the legacy field, modern clients ignore it.)
  9. CSGO-era documentation drift on CS2 semantics. node-globaloffensive’s README documents rank_type_id values for CSGO only (6=Matchmaking, 7=Wingman, 10=Danger Zone). Premier (CS2-only) is not in the repo set. Don’t ship rank_type_id == 11 → Premier without confirming the byte value against a known-Premier account. Same goes for Prime status — player_xp_bonus_flags is documented as “seems always null”, and CSOEconGameAccountClient decoding is commented out in node-globaloffensive. These are open empirical questions, not validated facts. See Validation Report for the full audit trail.