diablo2-web/tests/netproto/core-crypto-bncs-mcp.test.ts

251 lines
10 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { BitReader } from '../../src/netproto/core/bit-reader.ts'
import { BitWriter } from '../../src/netproto/core/bit-writer.ts'
import { ByteReader } from '../../src/netproto/core/byte-reader.ts'
import { ByteWriter } from '../../src/netproto/core/byte-writer.ts'
import { ProtocolError } from '../../src/netproto/core/errors.ts'
import { bytesToHex, hexToBytes } from '../../src/netproto/core/packet-tap.ts'
import { defaultTextCodec } from '../../src/netproto/core/text-codec.ts'
import { doubleHash, hashBnetPassword, hashRealmPassword } from '../../src/netproto/crypto/bnet-hash.ts'
import { createSyntheticCdKeyPair, decodeD2CdKey, hashD2CdKey } from '../../src/netproto/crypto/cdkey.ts'
import { getCheckRevision113c } from '../../src/netproto/crypto/check-revision.ts'
import { xsha1, xsha1Ascii } from '../../src/netproto/crypto/xsha1.ts'
import { BncsFramer, encodeBncsFrame } from '../../src/netproto/bncs/framing.ts'
import {
BncsOpcode,
decodeBncsS2cPacket,
encodeBncsLogonRealmEx,
encodeBncsPing,
} from '../../src/netproto/bncs/packets.ts'
import { BncsSession } from '../../src/netproto/bncs/session.ts'
import { McpFramer, encodeMcpFrame } from '../../src/netproto/mcp/framing.ts'
import {
McpOpcode,
decodeMcpS2cPacket,
encodeMcpStartup,
parseMcpCharStatString,
} from '../../src/netproto/mcp/packets.ts'
import { McpSession } from '../../src/netproto/mcp/session.ts'
import { createMemoryStreamPair } from '../../src/netproto/transport/memory-stream.ts'
describe('netproto core primitives (ByteReader/ByteWriter, BitReader/BitWriter, TextCodec)', () => {
it('round-trips little-endian integers and strings via ByteWriter and ByteReader', () => {
const w = new ByteWriter(16)
w.u8(0xfe)
w.i8(-12)
w.u16LE(0x1234)
w.i16LE(-3210)
w.u32LE(0xdeadbeef)
w.i32LE(-123456789)
w.cstring('Sanctuary')
w.fixedCString('Hero', 8)
const buf = w.toUint8Array()
const r = new ByteReader(buf)
expect(r.u8()).toBe(0xfe)
expect(r.i8()).toBe(-12)
expect(r.u16LE()).toBe(0x1234)
expect(r.i16LE()).toBe(-3210)
expect(r.u32LE()).toBe(0xdeadbeef)
expect(r.i32LE()).toBe(-123456789)
expect(r.cstring()).toBe('Sanctuary')
expect(r.fixedCString(8)).toBe('Hero')
expect(r.remaining).toBe(0)
})
it('throws ProtocolError when ByteReader reads past end of buffer', () => {
const r = new ByteReader(new Uint8Array([0x01, 0x02]), 0, 'd2gs', 0x01)
expect(r.u16LE()).toBe(0x0201)
expect(() => r.u8()).toThrow(ProtocolError)
})
it('round-trips arbitrary LSB-first bit widths and signed fields via BitWriter and BitReader', () => {
const bw = new BitWriter()
bw.writeBit(1)
bw.writeBit(0)
bw.writeBits(0x15, 5)
bw.writeBits(0x3ff, 10)
bw.writeBits(0xdeadbeef, 32)
bw.writeSignedBits(-42, 8)
bw.writeSignedBits(-1023, 12)
bw.writeAscii('gld')
bw.alignToByte()
bw.writeBits(0xab, 8)
const bytes = bw.toUint8Array()
const br = new BitReader(bytes)
expect(br.readBit()).toBe(1)
expect(br.readBit()).toBe(0)
expect(br.readBits(5)).toBe(0x15)
expect(br.readBits(10)).toBe(0x3ff)
expect(br.readBits(32)).toBe(0xdeadbeef)
expect(br.readSignedBits(8)).toBe(-42)
expect(br.readSignedBits(12)).toBe(-1023)
expect(br.readAscii(3)).toBe('gld')
br.alignToByte()
expect(br.readBits(8)).toBe(0xab)
expect(br.bitsRemaining).toBe(0)
expect(() => br.readBit()).toThrow(ProtocolError)
})
it('encodes and decodes UTF-8 and GBK strings cleanly', () => {
const ascii = defaultTextCodec.encode('Act1Town', 'utf-8')
expect(defaultTextCodec.decode(ascii, 'utf-8')).toBe('Act1Town')
// GBK bytes for "暗黑" (0xB0 0xB5 0xBA 0xDA)
const gbkBytes = new Uint8Array([0xb0, 0xb5, 0xba, 0xda])
expect(defaultTextCodec.decode(gbkBytes, 'gbk')).toBe('暗黑')
})
})
describe('netproto crypto (XSha1, BNet password hash, CD-Key hash, CheckRevision 1.13c)', () => {
it('computes deterministic Broken SHA-1 (xsha1) and double password hashes', () => {
const emptyDigest = bytesToHex(xsha1(new Uint8Array(0)))
expect(emptyDigest).toHaveLength(40)
// Lowercases password before hashing per PvPGN / Battle.net spec
const hUpper = bytesToHex(hashBnetPassword('MySecretPass123'))
const hLower = bytesToHex(hashBnetPassword('mysecretpass123'))
expect(hUpper).toBe(hLower)
const proof = doubleHash(0x509cb8b4, 0x24d6da71, hashBnetPassword('testpass'))
expect(proof.byteLength).toBe(20)
const realmProof = hashRealmPassword(0xb4b89c50, 0x24d6da71)
expect(bytesToHex(realmProof)).toBe('dfde6704ae8e22bf6a050ddb7c801979259b3233')
expect(xsha1Ascii('password')).toHaveLength(20)
})
it('decodes and hashes 16-char D2 CD-Keys and provides 1.13c CheckRevision constants', () => {
const pair = createSyntheticCdKeyPair(1)
const h1 = hashD2CdKey(0xb4b89c50, 0x24d6da71, pair.classic)
const h2 = hashD2CdKey(0xb4b89c50, 0x24d6da71, pair.expansion)
expect(h1.productValue).toBe(0x06)
expect(h2.productValue).toBe(0x0a)
expect(h1.hashedKeyData).toHaveLength(20)
expect(h2.hashedKeyData).toHaveLength(20)
const decoded = decodeD2CdKey('2678-4682-7468-2648', 0x06)
expect(decoded.keyLength).toBe(16)
expect(decoded.productValue).toBe(0x06)
const ver = getCheckRevision113c()
expect(ver.versionByte).toBe(0x0d)
})
})
describe('BNCS & MCP framing, packets, and sessions', () => {
it('frames fragmented BNCS packets and decodes live SID_LOGONREALMEX +MCP_STARTUP layout', () => {
// Live SID_LOGONREALMEX S->C packet from 101.37.117.183
const logonRealmHex =
'ff3e5600509cb8b4000000000000000010000000652575b717e1000071dad6240000000000000000505832440d0000000000000000000000cf16a2f62e712f5e640012aefa5d38214a478a866432776562626f743100'
const raw = hexToBytes(logonRealmHex)
const framer = new BncsFramer()
expect(framer.push(raw.subarray(0, 10))).toHaveLength(0)
const pkts = framer.push(raw.subarray(10))
expect(pkts).toHaveLength(1)
const decoded = decodeBncsS2cPacket(pkts[0]!)
expect(decoded.type).toBe('SID_LOGONREALMEX')
if (decoded.type === 'SID_LOGONREALMEX') {
expect(decoded.data.mcpCookie).toBe(0xb4b89c50)
expect(decoded.data.mcpStatus).toBe(0)
expect(decoded.data.mcpIp).toBe('101.37.117.183')
expect(decoded.data.mcpPort).toBe(6113)
expect(decoded.data.uniqueName).toBe('d2webbot1')
// MCP_STARTUP omits mcpIp/mcpPort (8B) -> 77 bytes total
const mcpStartup = encodeMcpStartup(decoded.data)
expect(bytesToHex(mcpStartup)).toBe(
'4d0001509cb8b400000000000000001000000071dad6240000000000000000505832440d0000000000000000000000cf16a2f62e712f5e640012aefa5d38214a478a866432776562626f743100',
)
}
})
it('parses MCP_CHARLIST2 33-byte statStrings accurately', () => {
const charListHex =
'3b00190800010000000100ffffff7f576562426f744f6e65008d80ffffffffff2dff4fffffff07ffffffffffffffffffffff01e080808001ffff00'
const mcpFramer = new McpFramer()
const pkts = mcpFramer.push(hexToBytes(charListHex))
expect(pkts).toHaveLength(1)
const decoded = decodeMcpS2cPacket(pkts[0]!)
expect(decoded.type).toBe('MCP_CHARLIST2')
if (decoded.type === 'MCP_CHARLIST2') {
expect(decoded.chars).toHaveLength(1)
expect(decoded.chars[0]!.name).toBe('WebBotOne')
expect(decoded.chars[0]!.charClass).toBe(6)
expect(decoded.chars[0]!.level).toBe(1)
expect(decoded.chars[0]!.expansion).toBe(true)
expect(decoded.chars[0]!.hardcore).toBe(false)
expect(decoded.chars[0]!.ladder).toBe(true)
}
// Also test Sorceress (classByte = 2 -> classId = 1)
const sorcStat = new Uint8Array(33).fill(0xff)
sorcStat[0] = 0x84
sorcStat[1] = 0x80
sorcStat[13] = 0x02 // Sorceress (1 + 1)
sorcStat[25] = 42 // level 42
sorcStat[26] = 0xe4 // 0x80 | LADDER (0x40) | EXPANSION (0x20) | HARDCORE (0x04)
sorcStat[27] = 0x80
const summary = parseMcpCharStatString('TestSorc', 123456, sorcStat)
expect(summary.charClass).toBe(1)
expect(summary.level).toBe(42)
expect(summary.expansion).toBe(true)
expect(summary.hardcore).toBe(true)
expect(summary.ladder).toBe(true)
})
it('auto-echoes SID_PING in BncsSession and completes MCP startup over MemoryStream', async () => {
const [clientStream, serverStream] = createMemoryStreamPair()
const bncs = new BncsSession(clientStream, { clientToken: 0x11223344 })
const serverReceived: Uint8Array[] = []
serverStream.onData((chunk) => serverReceived.push(chunk))
// Server sends SID_PING (0x25)
serverStream.write(encodeBncsPing(0xdeadbeef))
expect(serverReceived).toHaveLength(1)
expect(bytesToHex(serverReceived[0]!)).toBe('ff250800efbeadde')
bncs.close()
// Test McpSession startup + charLogon
const [mcpClientStream, mcpServerStream] = createMemoryStreamPair()
const mcp = new McpSession(mcpClientStream)
mcpServerStream.onData((chunk) => {
if (chunk.length >= 3 && chunk[2] === McpOpcode.MCP_STARTUP) {
mcpServerStream.write(encodeMcpFrame(McpOpcode.MCP_STARTUP, new Uint8Array([0, 0, 0, 0])))
} else if (chunk.length >= 3 && chunk[2] === McpOpcode.MCP_CHARLOGON) {
mcpServerStream.write(encodeMcpFrame(McpOpcode.MCP_CHARLOGON, new Uint8Array([0, 0, 0, 0])))
}
})
const status = await mcp.startup({
mcpCookie: 1,
mcpStatus: 0,
mcpChunk1: new Uint8Array(8),
mcpIp: '127.0.0.1',
mcpPort: 6113,
mcpChunk2: new Uint8Array(48),
uniqueName: 'bot',
})
expect(status).toBe(0)
expect(mcp.state).toBe('ready')
const logonStatus = await mcp.charLogon('WebBotOne')
expect(logonStatus).toBe(0)
expect(mcp.state).toBe('charLoggedIn')
expect(mcp.activeCharacter).toBe('WebBotOne')
mcp.close()
})
it('encodes SID_LOGONREALMEX request and decodes SID_SETEMAIL cleanly', () => {
const req = encodeBncsLogonRealmEx(0x11223344, new Uint8Array(20).fill(0xaa), 'Sanctuary')
expect(req[0]).toBe(0xff)
expect(req[1]).toBe(BncsOpcode.SID_LOGONREALMEX)
const setEmail = encodeBncsFrame(BncsOpcode.SID_SETEMAIL, new Uint8Array(0))
const framer = new BncsFramer()
const [rawPkt] = framer.push(setEmail)
expect(decodeBncsS2cPacket(rawPkt!).type).toBe('SID_SETEMAIL')
})
})