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 framing
    • EnvelopeEncryptedConnection.cs — Handshake orchestration and state machine
    • NetFilterEncryptionWithHMAC.cs — AES-256-CBC encryption with HMAC-SHA1 IVs
    • Util/KeyDictionary.cs — RSA public keys by universe
    • Base/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 implementation
    • components/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)

OffsetSizeFieldTypePurpose
04 bytesPayload Lengthuint32Length of the CM message body that follows
44 bytesMagic 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 bytes

node-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: payload

Receive 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; readUInt32LE explicitly 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 length bytes.
  • If fewer bytes are available than expected, the implementation blocks or returns (waiting for more data).
  • Once all length bytes 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 messages

Critical 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 protobuf

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

  1. The server sends ChannelEncryptResult.
  2. The client validates that Result == OK.
  3. 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:

  • ConnectedChallenged when sending ChannelEncryptResponse (line 190)
  • ChallengedEncrypted when 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 blob

Usage: 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-crypto all agree on this.

SteamKit2 (NetFilterEncryptionWithHMAC.cs:32-33):

hmacSecret = new byte[ 16 ];
Array.Copy( sessionKey, 0, hmacSecret, 0, 16 );  // FIRST 16 bytes

AES 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

ItemValueSource
TCP Frame Magic (LE uint32)0x31305456TcpConnection.cs:16
TCP Frame Magic (ASCII)“VT01”tcp.js:9
EMsg.ChannelEncryptRequest0x050B (1303)SteamLanguage.cs
EMsg.ChannelEncryptResponse0x050C (1304)SteamLanguage.cs
EMsg.ChannelEncryptResult0x050D (1305)SteamLanguage.cs
Encryption Protocol Version1MsgChannelEncryptRequest.PROTOCOL_VERSION
AES Key Size256 bits (32 bytes)NetFilterEncryptionWithHMAC.cs:28
RSA PaddingOAEP-SHA1EnvelopeEncryptedConnection.cs:168
RSA Key Size1024 bits (128 bytes ciphertext)KeyDictionary.cs
RSA Exponent (all universes)0x11 (17 decimal)KeyDictionary.cs
IV Length16 bytesNetFilterEncryptionWithHMAC.cs:17
IV Random Component3 bytesNetFilterEncryptionWithHMAC.cs:18
HMAC FunctionHMAC-SHA1NetFilterEncryptionWithHMAC.cs:110
HMAC Key SourceFirst 16 bytes of session keyNetFilterEncryptionWithHMAC.cs:32-33
AES ModeCBCNetFilterEncryptionWithHMAC.cs:66
AES PaddingPKCS7NetFilterEncryptionWithHMAC.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 EMsg enum values
    • Defines EResult enum values (including OK = 1)
    • Defines EUniverse enum values
  • Message Structures: Generated into SteamLanguageInternal.cs via protobuf code generation or manual definition.


Implementation Notes for New TCP Clients

  1. Initialize: Create TCP socket, connect to CM endpoint.

  2. Framing Loop: Implement state machine to read 8-byte header, validate magic, then read payload.

  3. Handshake:

    • Receive ChannelEncryptRequest (plaintext, MsgHdr header).
    • 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 ChannelEncryptResponse with encrypted key, CRC32, reserved=0.
    • Receive ChannelEncryptResult and validate Result == 1 (OK).
  4. Enable Encryption: Set session key. All future messages use AES-256-CBC with HMAC-SHA1 IV derivation.

  5. Send Loop: Before sending, derive IV from plaintext, encrypt IV with AES-ECB, encrypt payload with AES-CBC, wrap in 8-byte frame header.

  6. Receive Loop: Read frame, decrypt (if sessionKey set) using AES-ECB for IV then AES-CBC for payload, validate IV, parse header and dispatch.