Steam Connection Manager Transport Layer: TCP Protocol Reverse Engineering
Overview
This document details the TCP transport layer for Steam’s Connection Manager (CM) protocol, sufficient for implementing a compatible TCP client. The protocol consists of three main components: unencrypted TCP framing, an initialization handshake that establishes session keys, and symmetric encryption of subsequent messages.
Key Source Files Cross-Referenced
- SteamKit2 (C#):
/Volumes/bandi/coding/hypelevels/SteamKit/SteamKit2/SteamKit2/Networking/Steam3/TcpConnection.cs— TCP socket management and framingEnvelopeEncryptedConnection.cs— Handshake orchestration and state machineNetFilterEncryptionWithHMAC.cs— AES-256-CBC encryption with HMAC-SHA1 IVsUtil/KeyDictionary.cs— RSA public keys by universeBase/Generated/SteamLanguageInternal.cs— Message structure definitions
- node-steam-user (Node.js):
/Volumes/bandi/coding/hypelevels/node-steam-user/components/connection_protocols/tcp.js— Node.js TCP implementationcomponents/02-connection.js— Handshake message handlers
- Protobufs:
/Volumes/bandi/coding/hypelevels/Protobufs/steam/— Message structure definitions
SECTION A: TCP Packet Structure
8-Byte Unencrypted Framing Header
Every CM message sent over TCP is wrapped in an 8-byte unencrypted header that precedes the encrypted payload. This header is sent and validated before any encryption is applied; it is never encrypted.
Layout (Little-Endian)
| Offset | Size | Field | Type | Purpose |
|---|---|---|---|---|
| 0 | 4 bytes | Payload Length | uint32 | Length of the CM message body that follows |
| 4 | 4 bytes | Magic Value | ”VT01” | Literal ASCII characters (0x56 0x54 0x30 0x31 in hex, or 0x31305456 as LE uint32) |
The magic value is always 0x31305456 when interpreted as a little-endian 32-bit integer. This is the bytes ‘V’, ‘T’, ‘0’, ‘1’ in ASCII order written in little-endian.
Send Path (Encoding)
SteamKit2 encodes the header in TcpConnection.Send():
// TcpConnection.cs:327-328
netWriter!.Write((uint)data.Length); // 4 bytes, uint32, little-endian
netWriter.Write(MAGIC); // 4 bytes, value 0x31305456
netWriter.Write(data.Span); // payload bytesnode-steam-user encodes similarly in tcp.js:
// tcp.js:169-172
let buf = Buffer.alloc(4 + 4 + data.length);
buf.writeUInt32LE(data.length, 0); // offset 0: length, LE
buf.write(MAGIC, 4); // offset 4: "VT01"
data.copy(buf, 8); // offset 8: payloadReceive Path (Validation)
SteamKit2 validates the header in TcpConnection.ReadPacket():
// TcpConnection.cs:291-302
packetLen = netReader!.ReadUInt32(); // Read 4 bytes as uint32 LE
packetMagic = netReader.ReadUInt32(); // Read 4 bytes as uint32 LE
if (packetMagic != TcpConnection.MAGIC) // TcpConnection.cs:16 → 0x31305456
{
throw new IOException("Got a packet with invalid magic!");
}
byte[] packData = netReader.ReadBytes((int)packetLen);
if (packData.Length != packetLen)
{
throw new IOException("Connection lost while reading packet payload");
}node-steam-user validates similarly in tcp.js:
// tcp.js:189-199
let header = this.stream.read(8);
if (!header) {
return; // Incomplete header, wait for more data
}
this._messageLength = header.readUInt32LE(0);
if (header.slice(4).toString('ascii') != MAGIC) { // 'VT01'
this._fatal(new Error('Connection out of sync'));
return;
}Max Payload Sanity Check
SteamKit2: Implicitly enforced by reading (int)packetLen bytes. A length that would exceed 2^31-1 would wrap to negative; the .ReadBytes(int) API would fail with ArgumentOutOfRangeException.
node-steam-user: No explicit max-length check observed in tcp.js. The code reads this._messageLength bytes via this.stream.read(this._messageLength), which can be arbitrarily large within memory limits.
Endianness Confirmation
- Both implementations use little-endian for the 4-byte length field and magic value.
- Confirmed: BinaryReader/BinaryWriter default to little-endian in .NET;
readUInt32LEexplicitly specifies little-endian in Node.js.
Receive Loop State Machine
Both implementations follow the same state machine:
State 1: Waiting for Header
- Read exactly 8 bytes from the socket.
- Extract length (bytes 0-3) and magic (bytes 4-7).
- Validate magic ==
0x31305456("VT01"). - Transition to State 2.
State 2: Reading Payload
- Using the length from State 1, read exactly
lengthbytes. - If fewer bytes are available than expected, the implementation blocks or returns (waiting for more data).
- Once all
lengthbytes are read, pass the payload to the message dispatcher. - Transition back to State 1.
SteamKit2 (TcpConnection.cs:282-313):
void NetLoop()
{
while (!cancellationToken.IsCancellationRequested)
{
// ... socket polling ...
byte[] packData = ReadPacket(); // Blocks until a full packet is read
NetMsgReceived?.Invoke( this, new NetMsgEventArgs( packData, CurrentEndPoint! ) );
}
}
byte[] ReadPacket()
{
uint packetLen = netReader!.ReadUInt32(); // State 1: header length
uint packetMagic = netReader.ReadUInt32(); // State 1: header magic
if (packetMagic != TcpConnection.MAGIC) { ... }
byte[] packData = netReader.ReadBytes((int)packetLen); // State 2: payload
if (packData.Length != packetLen) { ... }
return packData;
}node-steam-user (tcp.js:186-234):
_readMessage()
{
if (!this._messageLength) { // State 1: no header yet
let header = this.stream.read(8);
if (!header) { return; } // Incomplete header
this._messageLength = header.readUInt32LE(0);
if (header.slice(4).toString('ascii') != MAGIC) { ... }
}
if (!this.stream) { return; } // State 2: stream check
let message = this.stream.read(this._messageLength); // Try to read payload
if (!message) { return; } // Incomplete payload
delete this._messageLength; // Back to State 1 for next message
// ... dispatch message ...
this._readMessage(); // Loop back
}SECTION B: Initial Handshake
The handshake is a three-message exchange that establishes a shared AES-256 session key. During the handshake, messages are not encrypted; encryption begins only after the client receives a successful ChannelEncryptResult.
Handshake Message Sequence
1. Server sends EMsg.ChannelEncryptRequest (0x050B)
Message Structure (non-protobuf classic struct):
The message body is a simple binary struct, wrapped in a MsgHdr header:
MsgHdr (20 bytes):
- Msg (4 bytes, uint32 LE): 0x050B (EMsg.ChannelEncryptRequest = 1303)
- TargetJobID (8 bytes, uint64 LE): 0xFFFFFFFFFFFFFFFF
- SourceJobID (8 bytes, uint64 LE): varies
Body (8 + 16+ bytes):
- ProtocolVersion (4 bytes, uint32 LE): 1
- Universe (4 bytes, uint32 LE/as int32): 0 (Public), 2 (Beta), 3 (Internal), 4 (Dev)
- RandomChallenge (16+ bytes): random nonce from server
SteamKit2 parsing (EnvelopeEncryptedConnection.cs:131-143):
void HandleEncryptRequest( IPacketMsg packetMsg )
{
var request = new Msg<MsgChannelEncryptRequest>( packetMsg );
var connectedUniverse = request.Body.Universe; // EUniverse enum
var protoVersion = request.Body.ProtocolVersion; // Should be 1
// ... assertion checks ...
var randomChallenge = request.Payload.ToArray(); // 16+ byte nonce
}Message struct definition (SteamLanguageInternal.cs:505-537):
public class MsgChannelEncryptRequest : ISteamSerializableMessage
{
public EMsg GetEMsg() { return EMsg.ChannelEncryptRequest; }
public static readonly uint PROTOCOL_VERSION = 1;
public uint ProtocolVersion { get; set; } // uint32
public EUniverse Universe { get; set; } // uint32 (as enum)
// Body continues with random challenge bytes
}node-steam-user parsing (02-connection.js:104-109):
let protocol = body.readUint32(); // ProtocolVersion
let universe = body.readUint32(); // Universe
let nonce = body.slice(body.offset, body.offset + 16).toBuffer(); // 16-byte nonce
body.skip(16);Universe Values (from SteamLanguage.cs enum):
- 0 or Invalid: Invalid
- 1: Public (production)
- 2: Beta
- 3: Internal
- 4: Dev
2. Client responds with EMsg.ChannelEncryptResponse (0x050C)
Message Structure (non-protobuf classic struct):
MsgHdr (20 bytes):
- Msg (4 bytes, uint32 LE): 0x050C (EMsg.ChannelEncryptResponse = 1304)
- TargetJobID (8 bytes, uint64 LE): 0xFFFFFFFFFFFFFFFF
- SourceJobID (8 bytes, uint64 LE): value from request
Body:
- ProtocolVersion (4 bytes, uint32 LE): 1 (same as request)
- KeySize (4 bytes, uint32 LE): 128 (size of encrypted blob in bytes)
- EncryptedKey (128 bytes): RSA-OAEP encrypted session key + nonce
- KeyCrc32 (4 bytes, uint32 LE): CRC32 of the encrypted blob
- Reserved (4 bytes, uint32 LE): 0 (always zeros)
SteamKit2 construction (EnvelopeEncryptedConnection.cs:155-176):
var response = new Msg<MsgChannelEncryptResponse>();
var tempSessionKey = RandomNumberGenerator.GetBytes( 32 ); // 32-byte AES key
byte[] encryptedHandshakeBlob;
using ( var rsa = RSA.Create() )
{
rsa.ImportSubjectPublicKeyInfo( publicKey, out _ );
var blobToEncrypt = new byte[ tempSessionKey.Length + randomChallenge.Length ];
Array.Copy( tempSessionKey, blobToEncrypt, tempSessionKey.Length );
Array.Copy( randomChallenge, 0, blobToEncrypt, tempSessionKey.Length, randomChallenge.Length );
encryptedHandshakeBlob = rsa.Encrypt( blobToEncrypt, RSAEncryptionPadding.OaepSHA1 ); // 128 bytes
}
var keyCrc = Crc32.Hash( encryptedHandshakeBlob );
response.Write( encryptedHandshakeBlob );
response.Write( keyCrc );
response.Write( ( uint )0 );node-steam-user construction (02-connection.js:111-121):
let sessionKey = SteamCrypto.generateSessionKey(nonce); // Generates both encrypted + plain versions
let keyCrc = StdLib.Hashing.crc32(sessionKey.encrypted);
let encResp = ByteBuffer.allocate(4 + 4 + sessionKey.encrypted.length + 4 + 4, ...);
encResp.writeUint32(protocol);
encResp.writeUint32(sessionKey.encrypted.length); // Should be 128
encResp.append(sessionKey.encrypted);
encResp.writeUint32(keyCrc);
encResp.writeUint32(0);MsgChannelEncryptResponse definition (SteamLanguageInternal.cs:539-570):
public class MsgChannelEncryptResponse : ISteamSerializableMessage
{
public EMsg GetEMsg() { return EMsg.ChannelEncryptResponse; }
public uint ProtocolVersion { get; set; } // uint32
public uint KeySize { get; set; } // uint32 (128 for RSA-1024)
// Body continues: encrypted key, CRC32, reserved uint32
}3. Server sends EMsg.ChannelEncryptResult (0x050D)
Message Structure (non-protobuf classic struct):
MsgHdr (20 bytes):
- Msg (4 bytes, uint32 LE): 0x050D (EMsg.ChannelEncryptResult = 1305)
- TargetJobID (8 bytes, uint64 LE): varies
- SourceJobID (8 bytes, uint64 LE): varies
Body:
- Result (4 bytes, int32 LE): EResult enum value (1 = OK, else failure)
MsgChannelEncryptResult definition (SteamLanguageInternal.cs:572-598):
public class MsgChannelEncryptResult : ISteamSerializableMessage
{
public EMsg GetEMsg() { return EMsg.ChannelEncryptResult; }
public EResult Result { get; set; } // int32 as enum
}SteamKit2 validation (EnvelopeEncryptedConnection.cs:194-211):
void HandleEncryptResult( IPacketMsg packetMsg )
{
var result = new Msg<MsgChannelEncryptResult>( packetMsg );
if ( result.Body.Result == EResult.OK && encryption != null )
{
state = EncryptionState.Encrypted;
Connected?.Invoke( this, EventArgs.Empty );
}
else
{
// Handshake failed
Disconnect( userInitiated: false );
}
}node-steam-user validation (02-connection.js:127-142):
let eresult = body.readUint32();
if (eresult != EResult.OK) {
this.emit('error', new Error('Encryption failed: ' + eresult));
this._disconnect(true);
return;
}
this._connection.sessionKey = this._connection._tempSessionKey;
// Now encryption is enabled for subsequent messagesCritical Requirement: The client MUST verify that Result == EResult.OK (value 1) before enabling encryption. If the result is non-OK, the handshake has failed and the connection should be torn down.
Message Header Format During Handshake
All three handshake messages use the classic MsgHdr format (20 bytes), not protobuf headers. The MsgHdr is always used for non-protobuf messages like the encryption handshake.
MsgHdr Structure (SteamLanguageInternal.cs:228-264):
public class MsgHdr : ISteamSerializableHeader
{
public EMsg Msg { get; set; } // 4 bytes, uint32
public ulong TargetJobID { get; set; } // 8 bytes, uint64
public ulong SourceJobID { get; set; } // 8 bytes, uint64
}Both SteamKit and node-steam-user parse handshake messages using the MsgHdr header, as specified in CMClient.GetPacketMsg() (CMClient.cs:507-510):
case EMsg.ChannelEncryptRequest:
case EMsg.ChannelEncryptResponse:
case EMsg.ChannelEncryptResult:
return new PacketMsg( eMsg, data ); // Uses MsgHdr, not protobufConnection State Between ChannelEncryptResponse and ChannelEncryptResult
Critical behavior: After the client sends ChannelEncryptResponse, it does NOT immediately enable encryption. The client remains in plaintext mode until:
- The server sends
ChannelEncryptResult. - The client validates that
Result == OK. - The client transitions the connection to
EncryptionState.Encrypted.
SteamKit2 (EnvelopeEncryptedConnection.cs:225-231):
enum EncryptionState
{
Disconnected,
Connected, // Socket connected, awaiting ChannelEncryptRequest
Challenged, // Sent ChannelEncryptResponse, awaiting ChannelEncryptResult
Encrypted // Encryption enabled, transition triggered by OK result
}State transitions:
Connected→Challengedwhen sending ChannelEncryptResponse (line 190)Challenged→Encryptedwhen receiving OK ChannelEncryptResult (line 203)
node-steam-user uses a similar pattern, storing the session key temporarily:
// 02-connection.js:112
this._connection._tempSessionKey = sessionKey.plain;
// 02-connection.js:137
this._connection.sessionKey = this._connection._tempSessionKey;Until sessionKey is set, all send/receive operations remain unencrypted.
SECTION C: Crypto Setup
AES-256 Session Key Generation and Use
Key Derivation
A new 32-byte random session key is generated by the client for each handshake.
SteamKit2 (EnvelopeEncryptedConnection.cs:157):
var tempSessionKey = RandomNumberGenerator.GetBytes( 32 );node-steam-user (02-connection.js:111, via external SteamCrypto library):
let sessionKey = SteamCrypto.generateSessionKey(nonce);
// sessionKey.plain contains the 32 plaintext bytes
// sessionKey.encrypted contains the RSA-encrypted blobUsage: This 32-byte key is used as the AES key for all subsequent symmetric encryption:
SteamKit2 (NetFilterEncryptionWithHMAC.cs:24-38):
public NetFilterEncryptionWithHMAC( byte[] sessionKey, ILogContext log )
{
DebugLog.Assert( sessionKey.Length == 32, ... );
hmacSecret = new byte[ 16 ];
Array.Copy( sessionKey, 0, hmacSecret, 0, 16 ); // First 16 bytes for HMAC-SHA1 key
aes = Aes.Create();
aes.KeySize = 256;
aes.Key = sessionKey; // Full 32 bytes as AES-256 key
}RSA Key Wrapping
Steam’s Public RSA Keys
Steam publishes different 1024-bit RSA public keys for each universe. These are embedded in both SteamKit and inferred by node-steam-user’s external crypto library.
SteamKit2 (KeyDictionary.cs:22-76) stores the keys as DER-encoded SubjectPublicKeyInfo structures:
For EUniverse.Public (production):
30 81 9D -- SEQUENCE, length 157 bytes
30 0D -- SEQUENCE (AlgorithmIdentifier)
06 09 2A 86 48 86 F7 0D 01 01 01 -- OID for RSA encryption
05 00 -- NULL
03 81 8B 00 -- BIT STRING, length 139 bytes
30 81 87 -- SEQUENCE (RSAPublicKey)
02 81 81 00 -- INTEGER, modulus (129 bytes including leading 0)
DF EC 1A D6 2C 10 66 2C ... (1024-bit modulus)
02 01 11 -- INTEGER, exponent (1 byte = 0x11 = 17 decimal)
The public exponent is consistently 0x11 (17 decimal) across all universes. The modulus (N) is 1024 bits (128 bytes).
Keys for all universes (KeyDictionary.cs:18-77):
EUniverse.Public: Modulus starting with 0xDFEC1A…EUniverse.Beta: Modulus starting with 0xAED14B…EUniverse.Internal: Modulus starting with 0xA8FE01…EUniverse.Dev: Modulus starting with 0xD0052C…
RSA Padding Scheme
Padding: RSA-OAEP with SHA-1 as both the hash function and MGF1 hash.
SteamKit2 (EnvelopeEncryptedConnection.cs:168):
encryptedHandshakeBlob = rsa.Encrypt( blobToEncrypt, RSAEncryptionPadding.OaepSHA1 );RSAEncryptionPadding.OaepSHA1 is a standard .NET constant that specifies:
- Hash function: SHA-1
- MGF1 hash: SHA-1 (default for OAEP)
- Label: none (empty byte array)
Resulting ciphertext length: 128 bytes (matching 1024-bit / 8 bits/byte modulus).
Blob Structure Pre-Encryption
The plaintext blob sent to RSA encryption is the concatenation:
[SessionKey (32 bytes)] + [RandomChallenge (16+ bytes)]
SteamKit2 (EnvelopeEncryptedConnection.cs:164-166):
var blobToEncrypt = new byte[ tempSessionKey.Length + randomChallenge.Length ];
Array.Copy( tempSessionKey, blobToEncrypt, tempSessionKey.Length );
Array.Copy( randomChallenge, 0, blobToEncrypt, tempSessionKey.Length, randomChallenge.Length );node-steam-user: The external SteamCrypto.generateSessionKey() function handles this internally, taking the nonce as input and producing both the plaintext and encrypted forms.
CRC32 Over Encrypted Blob
A CRC32 checksum is computed over the encrypted RSA blob and sent in ChannelEncryptResponse as a sanity check (though the server does not appear to validate it strictly).
SteamKit2 (EnvelopeEncryptedConnection.cs:171):
var keyCrc = Crc32.Hash( encryptedHandshakeBlob );Uses the standard CRC32 polynomial (0x04C11DB7, reflected, initial value 0xFFFFFFFF, final XOR 0xFFFFFFFF).
node-steam-user (02-connection.js:113):
let keyCrc = StdLib.Hashing.crc32(sessionKey.encrypted);The CRC32 is written as a 4-byte uint32 LE value following the encrypted key in the response body.
SECTION D: Bulk Encryption (After Handshake)
AES-256-CBC with HMAC-SHA1 IV Derivation
Once ChannelEncryptResult confirms success, all subsequent CM messages are encrypted using AES-256 in CBC mode with a derived initialization vector (IV).
The IV for each message is derived using HMAC-SHA1 over a combination of random bytes and the plaintext.
IV Derivation Algorithm
The IV is 16 bytes total, composed of:
- 13 bytes: HMAC-SHA1 hash (truncated from 20 bytes to 13)
- 3 bytes: Random data
SteamKit2 (NetFilterEncryptionWithHMAC.cs:92-118):
void GenerateInitializationVector( Span<byte> plainText, Span<byte> iv )
{
var hashLength = InitializationVectorLength - InitializationVectorRandomLength; // 16 - 3 = 13
RandomNumberGenerator.Fill( iv[ hashLength.. ] ); // Fill last 3 bytes with random
var hmacBufferLength = plainText.Length + InitializationVectorRandomLength; // Plaintext + 3 random
var hmacBuffer = ArrayPool<byte>.Shared.Rent( hmacBufferLength );
try
{
// HMAC-SHA1 input: [Random(3) + Plaintext]
iv[ ^InitializationVectorRandomLength.. ].CopyTo( hmacBufferSpan[ ..InitializationVectorRandomLength ] );
plainText.CopyTo( hmacBufferSpan[ InitializationVectorRandomLength.. ] );
// HMAC key: first 16 bytes of session key
Span<byte> hashValue = stackalloc byte[ HMACSHA1.HashSizeInBytes ]; // 20 bytes
HMACSHA1.HashData( hmacSecret, hmacBufferSpan, hashValue );
// Use first 13 bytes of hash as first 13 bytes of IV
hashValue[ ..hashLength ].CopyTo( iv );
}
finally
{
ArrayPool<byte>.Shared.Return( hmacBuffer );
}
}Final IV composition:
[HMAC-SHA1(hmacSecret, random_3_bytes + plaintext)[:13]] + [random_3_bytes]
where hmacSecret = first 16 bytes of the 32-byte session key.
HMAC Key Derivation
HMAC key is the first 16 bytes of the session key
The HMAC-SHA1 key is the first 16 bytes of the 32-byte AES session key, not the last 16 bytes. Get this wrong and decryption silently produces garbage that looks plausible. SteamKit, node-steam-user, and
@doctormckay/steam-cryptoall agree on this.
SteamKit2 (NetFilterEncryptionWithHMAC.cs:32-33):
hmacSecret = new byte[ 16 ];
Array.Copy( sessionKey, 0, hmacSecret, 0, 16 ); // FIRST 16 bytesAES Encryption Mode
Cipher: AES-256-CBC
- Block size: 128 bits (16 bytes)
- Key: full 32-byte session key
- IV: 16-byte derived IV (composed as above)
- Padding: PKCS7
SteamKit2 (NetFilterEncryptionWithHMAC.cs:76-90):
int SymmetricEncryptWithHMACIV( Span<byte> input, byte[] output )
{
Span<byte> iv = stackalloc byte[ InitializationVectorLength ]; // 16 bytes
GenerateInitializationVector( input, iv );
var outputSpan = output.AsSpan();
// Encrypt the IV itself using AES-ECB (no padding)
var cryptedIvLength = aes.EncryptEcb( iv, outputSpan, PaddingMode.None ); // 16 bytes
// Encrypt plaintext using AES-CBC with the derived IV
var cipherTextLength = aes.EncryptCbc( input, iv, outputSpan[ cryptedIvLength.. ], PaddingMode.PKCS7 );
return cryptedIvLength + cipherTextLength;
}Message Framing Under Encryption
Each CM message gets its own IV and encryption.
The ciphertext on the wire is:
[EncryptedIV (16 bytes)] + [EncryptedPayload]
where:
EncryptedIV= the 16-byte derived IV encrypted using AES-ECB (no padding)EncryptedPayload= the plaintext CM message encrypted using AES-CBC with the same IV
This entire encrypted blob is then wrapped in the TCP frame header (8-byte length + magic) and sent as the “payload” of a TCP message.
Decryption Path
SteamKit2 (NetFilterEncryptionWithHMAC.cs:61-71):
byte[] SymmetricDecryptHMACIV( Span<byte> input )
{
Span<byte> iv = stackalloc byte[ InitializationVectorLength ]; // 16 bytes
// First 16 bytes: decrypt the IV using AES-ECB
aes.DecryptEcb( input[ ..iv.Length ], iv, PaddingMode.None );
// Remaining bytes: decrypt using AES-CBC with the recovered IV
byte[] plainText = aes.DecryptCbc( input[ iv.Length.. ], iv, PaddingMode.PKCS7 );
ValidateInitializationVector( plainText, iv );
return plainText;
}Validation: After decryption, the IV is re-computed from the plaintext and compared against the received IV to detect tampering.
Comparison: node-steam-user and SteamKit2 Divergence
Both implementations use the same HMAC-IV mode (AES-256-CBC with HMAC-SHA1 IV derivation). There is no legacy CBC fallback in the observed code; both libraries always use this mode for post-handshake encryption. The external SteamCrypto library in node-steam-user (@doctormckay/steam-crypto) encapsulates the same algorithm.
SECTION E: End-to-End Message Flow
Receive Pipeline: Socket Bytes → CM Message
1. TCP Socket
↓
2. Read 8-byte Frame Header (length + "VT01" magic)
[/Volumes/bandi/coding/hypelevels/SteamKit/SteamKit2/SteamKit2/Networking/Steam3/TcpConnection.cs:282-302]
[/Volumes/bandi/coding/hypelevels/node-steam-user/components/connection_protocols/tcp.js:186-200]
↓
3. Read encrypted payload (length bytes from frame)
↓
4. If sessionKey is set: AES-256-CBC decrypt with HMAC-IV validation
[/Volumes/bandi/coding/hypelevels/SteamKit/SteamKit2/SteamKit2/Networking/Steam3/NetFilterEncryptionWithHMAC.cs:41-54]
[/Volumes/bandi/coding/hypelevels/node-steam-user/components/connection_protocols/tcp.js:222-228]
↓
5. Parse message header (MsgHdr or MsgHdrProtoBuf)
[/Volumes/bandi/coding/hypelevels/SteamKit/SteamKit2/SteamKit2/Steam/CMClient.cs:493-531]
↓
6. Extract EMsg and route to handler
[/Volumes/bandi/coding/hypelevels/SteamKit/SteamKit2/SteamKit2/Steam/CMClient.cs:504-525]
Send Pipeline: CM Message → Socket Bytes
1. CM Message (header + body)
↓
2. If sessionKey is set: AES-256-CBC encrypt with HMAC-IV derivation
[/Volumes/bandi/coding/hypelevels/SteamKit/SteamKit2/SteamKit2/Networking/Steam3/EnvelopeEncryptedConnection.cs:51-71]
[/Volumes/bandi/coding/hypelevels/node-steam-user/components/connection_protocols/tcp.js:160-162]
↓
3. Construct TCP Frame: [length (4 bytes LE)] + ["VT01" (4 bytes)] + [encrypted payload]
[/Volumes/bandi/coding/hypelevels/SteamKit/SteamKit2/SteamKit2/Networking/Steam3/TcpConnection.cs:315-336]
[/Volumes/bandi/coding/hypelevels/node-steam-user/components/connection_protocols/tcp.js:159-180]
↓
4. Write to TCP socket
Handshake Timeline Diagram
Client Server
| |
|--- TCP 3-way handshake ------------------>|
| |
|<------ ChannelEncryptRequest ------------|
| (EMsg=1303, nonce, universe) |
| [State: Connected → Challenged] |
| |
|--- ChannelEncryptResponse -------->|
| (EMsg=1304, encrypted key, [Validate CRC32]
| CRC32, reserved) [Derive session key]
| [State: Challenged] |
| |
|<------ ChannelEncryptResult -------|
| (EMsg=1305, result=OK) |
| [If Result==OK: State → Encrypted]
| [Enable encryption for all future msgs]
|
|--- ChannelLogOn (encrypted) ------->|
| [All subsequent msgs encrypted] [Etc.]
|
Summary Table: Key Constants and Values
| Item | Value | Source |
|---|---|---|
| TCP Frame Magic (LE uint32) | 0x31305456 | TcpConnection.cs:16 |
| TCP Frame Magic (ASCII) | “VT01” | tcp.js:9 |
| EMsg.ChannelEncryptRequest | 0x050B (1303) | SteamLanguage.cs |
| EMsg.ChannelEncryptResponse | 0x050C (1304) | SteamLanguage.cs |
| EMsg.ChannelEncryptResult | 0x050D (1305) | SteamLanguage.cs |
| Encryption Protocol Version | 1 | MsgChannelEncryptRequest.PROTOCOL_VERSION |
| AES Key Size | 256 bits (32 bytes) | NetFilterEncryptionWithHMAC.cs:28 |
| RSA Padding | OAEP-SHA1 | EnvelopeEncryptedConnection.cs:168 |
| RSA Key Size | 1024 bits (128 bytes ciphertext) | KeyDictionary.cs |
| RSA Exponent (all universes) | 0x11 (17 decimal) | KeyDictionary.cs |
| IV Length | 16 bytes | NetFilterEncryptionWithHMAC.cs:17 |
| IV Random Component | 3 bytes | NetFilterEncryptionWithHMAC.cs:18 |
| HMAC Function | HMAC-SHA1 | NetFilterEncryptionWithHMAC.cs:110 |
| HMAC Key Source | First 16 bytes of session key | NetFilterEncryptionWithHMAC.cs:32-33 |
| AES Mode | CBC | NetFilterEncryptionWithHMAC.cs:66 |
| AES Padding | PKCS7 | NetFilterEncryptionWithHMAC.cs:66 |
References to External Protobuf Definitions
Message field definitions are auto-generated from .proto files in the repository:
-
Steam Protocol Enums:
/Volumes/bandi/coding/hypelevels/Protobufs/steam/enums.proto,enums_clientserver.proto- Defines
EMsgenum values - Defines
EResultenum values (includingOK = 1) - Defines
EUniverseenum values
- Defines
-
Message Structures: Generated into
SteamLanguageInternal.csvia protobuf code generation or manual definition.
Implementation Notes for New TCP Clients
-
Initialize: Create TCP socket, connect to CM endpoint.
-
Framing Loop: Implement state machine to read 8-byte header, validate magic, then read payload.
-
Handshake:
- Receive
ChannelEncryptRequest(plaintext,MsgHdrheader). - Extract universe, protocol version, and nonce.
- Generate 32-byte random session key.
- Look up public RSA key by universe from
KeyDictionary. - Create blob = [sessionKey (32 bytes) + nonce (16+ bytes)].
- Encrypt blob with RSA-OAEP-SHA1 (produces 128 bytes).
- Compute CRC32 of encrypted blob.
- Send
ChannelEncryptResponsewith encrypted key, CRC32, reserved=0. - Receive
ChannelEncryptResultand validateResult == 1(OK).
- Receive
-
Enable Encryption: Set session key. All future messages use AES-256-CBC with HMAC-SHA1 IV derivation.
-
Send Loop: Before sending, derive IV from plaintext, encrypt IV with AES-ECB, encrypt payload with AES-CBC, wrap in 8-byte frame header.
-
Receive Loop: Read frame, decrypt (if sessionKey set) using AES-ECB for IV then AES-CBC for payload, validate IV, parse header and dispatch.