From 233f0d33a660153f3005e6b2b1a7bb061f60ce37 Mon Sep 17 00:00:00 2001 From: extremeheat Date: Sat, 13 Feb 2021 18:42:43 -0500 Subject: [PATCH] workingish client connection to server Joining a vanilla server is still broken (related to encryption), but minet connects --- data/new/proto.yml | 14 ++-- data/new/types.yaml | 25 +++--- data/newproto.json | 51 ++++++------- src/auth/encryption.js | 113 ++++++++++++++++++++++++---- src/client.js | 42 +++++++---- src/client/auth.js | 4 +- src/client/authFlow.js | 7 +- src/client/tokens.js | 2 +- src/clientTest.js | 11 ++- src/connection.js | 4 +- src/datatypes/compiler-minecraft.js | 6 +- src/serverTest.js | 2 +- src/transforms/encryption.js | 53 ++++++++++--- 13 files changed, 231 insertions(+), 103 deletions(-) diff --git a/data/new/proto.yml b/data/new/proto.yml index 5bf4498..a3fe763 100644 --- a/data/new/proto.yml +++ b/data/new/proto.yml @@ -197,13 +197,13 @@ packet_start_game: entity_id: zigzag64 # The runtime ID of the player. The runtime ID is unique for each world session, # and entities are generally identified in packets using this runtime ID. - runtime_entity_id: varint + runtime_entity_id: varint64 player_gamemode: zigzag32 # The spawn position of the player in the world. In servers this is often the same as the # world's spawn position found below. spawn: vec3f # The pitch and yaw of the player - rotation: vec3f + rotation: vec2f # The seed used to generate the world. Unlike in Java edition, the seed is a 32bit Integer here. seed: zigzag32 biome_type: li16 @@ -302,8 +302,8 @@ packet_start_game: # The version of the game from which Vanilla features will be used. # The exact function of this field isn't clear. game_version: string - limited_world_width_: li32 - limited_world_length_: li32 + limited_world_width: li32 + limited_world_length: li32 is_new_nether_: bool experimental_gameplay_override: bool # A base64 encoded world ID that is used to identify the world. @@ -335,7 +335,7 @@ packet_start_game: ## This is not sent anymore in protocol versions > 419 (Bedrock Edition v1.16.100) ## A list of all blocks registered on the server. - ## block_palette: BlockPalette + block_palette: BlockPalette # A list of all items with their legacy IDs which are available in the game. # Failing to send any of the items that are in the game will crash mobile clients. itemstates: Itemstates @@ -525,9 +525,9 @@ packet_mob_effect: packet_update_attributes: !id: 0x1d !bound: server - runtime_entity_id: varint + runtime_entity_id: varint64 attributes: PlayerAttributes - tick: varint + tick: varint64 packet_inventory_transaction: !id: 0x1e diff --git a/data/new/types.yaml b/data/new/types.yaml index 2168098..f53f36d 100644 --- a/data/new/types.yaml +++ b/data/new/types.yaml @@ -3,7 +3,7 @@ BehaviourPackInfos: []li16 uuid: string version: string - length: lu64 + size: lu64 content_key: string sub_pack_name: string content_identity: string @@ -12,7 +12,7 @@ BehaviourPackInfos: []li16 TexturePackInfos: []li16 uuid: string version: string - length: lu64 + size: lu64 content_key: string sub_pack_name: string content_identity: string @@ -27,13 +27,13 @@ ResourcePackIdVersions: []varint # The subpack name of the resource pack. name: string -ResourcePackIds: string[]varint +ResourcePackIds: string[]li16 Experiment: name: string enabled: bool -Experiments: Experiment[]varint +Experiments: Experiment[]li32 GameRule: name: string @@ -43,7 +43,7 @@ GameRule: 3: float value: type? if bool: bool - if int: varint + if int: zigzag32 if float: lf32 GameRules: GameRule[]varint @@ -150,10 +150,10 @@ BlockCoordinates: # mojang... z: zigzag32 PlayerAttributes: []varint - min_value: lf32 - max_value: lf32 - current_value: lf32 - default_value: lf32 + min: lf32 + max: lf32 + current: lf32 + default: lf32 name: string Transaction: @@ -190,7 +190,7 @@ Transaction: slot: varint old_item: Item new_item: Item - new_item_stack_id: has_network_ids? + new_item_stack_id: ../has_network_ids? if true: zigzag32 default: void transaction_data: transaction_type? @@ -242,7 +242,7 @@ PotionContainerChangeRecipes: []varint output_item_id: zigzag32 Recipes: []varint - type: varint => + type: zigzag32 => '0': 'shapeless' #'ENTRY_SHAPELESS', '1': 'shaped' #'ENTRY_SHAPED', '2': 'furnace' # 'ENTRY_FURNACE', @@ -341,9 +341,6 @@ PlayerRecords: is_host: bool if remove: uuid: uuid - uuid: type? - if add: uuid - default: void verified: bool[]$records_count ScoreEntries: diff --git a/data/newproto.json b/data/newproto.json index e520a62..f2a320f 100644 --- a/data/newproto.json +++ b/data/newproto.json @@ -25,7 +25,7 @@ "type": "string" }, { - "name": "length", + "name": "size", "type": "lu64" }, { @@ -64,7 +64,7 @@ "type": "string" }, { - "name": "length", + "name": "size", "type": "lu64" }, { @@ -117,7 +117,7 @@ "ResourcePackIds": [ "array", { - "countType": "varint", + "countType": "li16", "type": "string" } ], @@ -137,7 +137,7 @@ "Experiments": [ "array", { - "countType": "varint", + "countType": "li32", "type": "Experiment" } ], @@ -170,7 +170,7 @@ "compareTo": "type", "fields": { "bool": "bool", - "int": "varint", + "int": "zigzag32", "float": "lf32" }, "default": "void" @@ -580,19 +580,19 @@ "container", [ { - "name": "min_value", + "name": "min", "type": "lf32" }, { - "name": "max_value", + "name": "max", "type": "lf32" }, { - "name": "current_value", + "name": "current", "type": "lf32" }, { - "name": "default_value", + "name": "default", "type": "lf32" }, { @@ -775,7 +775,7 @@ "type": [ "switch", { - "compareTo": "has_network_ids", + "compareTo": "../has_network_ids", "fields": { "true": "zigzag32" }, @@ -1010,7 +1010,7 @@ "type": [ "mapper", { - "type": "varint", + "type": "zigzag32", "mappings": { "0": "shapeless", "1": "shaped", @@ -1449,19 +1449,6 @@ } ] }, - { - "name": "uuid", - "type": [ - "switch", - { - "compareTo": "type", - "fields": { - "add": "uuid" - }, - "default": "void" - } - ] - }, { "name": "verified", "type": [ @@ -2671,7 +2658,7 @@ }, { "name": "runtime_entity_id", - "type": "varint" + "type": "varint64" }, { "name": "player_gamemode", @@ -2683,7 +2670,7 @@ }, { "name": "rotation", - "type": "vec3f" + "type": "vec2f" }, { "name": "seed", @@ -2834,11 +2821,11 @@ "type": "string" }, { - "name": "limited_world_width_", + "name": "limited_world_width", "type": "li32" }, { - "name": "limited_world_length_", + "name": "limited_world_length", "type": "li32" }, { @@ -2887,6 +2874,10 @@ "name": "enchantment_seed", "type": "zigzag32" }, + { + "name": "block_palette", + "type": "BlockPalette" + }, { "name": "itemstates", "type": "Itemstates" @@ -3390,7 +3381,7 @@ [ { "name": "runtime_entity_id", - "type": "varint" + "type": "varint64" }, { "name": "attributes", @@ -3398,7 +3389,7 @@ }, { "name": "tick", - "type": "varint" + "type": "varint64" } ] ], diff --git a/src/auth/encryption.js b/src/auth/encryption.js index f32bc69..956eede 100644 --- a/src/auth/encryption.js +++ b/src/auth/encryption.js @@ -6,11 +6,11 @@ const ec_pem = require('ec-pem') const SALT = '🧂' const curve = 'secp384r1' -function Encrypt(client, options) { +function Encrypt(client, server, options) { client.ecdhKeyPair = crypto.createECDH(curve) client.ecdhKeyPair.generateKeys() + client.clientX509 = writeX509PublicKey(client.ecdhKeyPair.getPublicKey()) - createClientChain(client) function startClientboundEncryption(publicKey) { console.warn('[encrypt] Pub key base64: ', publicKey) @@ -31,9 +31,11 @@ function Encrypt(client, options) { const secretHash = crypto.createHash('sha256') secretHash.update(SALT) secretHash.update(client.sharedSecret) + console.log('---- SHARED SECRET', client.sharedSecret) + client.secretKeyBytes = secretHash.digest() - + console.log('Hash', client.secretKeyBytes) const x509 = writeX509PublicKey(alice.getPublicKey()) const token = JWT.sign({ salt: toBase64(SALT), @@ -51,30 +53,109 @@ function Encrypt(client, options) { client.startEncryption(initial) } - function startServerboundEncryption() { + function startServerboundEncryption(token) { + console.warn('Starting serverbound encryption', token) + const jwt = token?.token + if (!jwt) { + // TODO: allow connecting to servers without encryption + throw Error('Server did not return a valid JWT, cannot start encryption!') + } + // TODO: Should we do some JWT signature validation here? Seems pointless + const alice = client.ecdhKeyPair + const [header, payload, signature] = jwt.split('.').map(k => Buffer.from(k, 'base64')) + const head = JSON.parse(String(header)) + const body = JSON.parse(String(payload)) + const serverPublicKey = readX509PublicKey(head.x5u) + client.sharedSecret = alice.computeSecret(serverPublicKey) + console.log('------ SHARED SECRET', client.sharedSecret) + const salt = Buffer.from(body.salt, 'base64') + + const secretHash = crypto.createHash('sha256') + secretHash.update(salt) + secretHash.update(client.sharedSecret) + + client.secretKeyBytes = secretHash.digest() + console.log('Hash', client.secretKeyBytes) + const initial = client.secretKeyBytes.slice(0, 16) + client.startEncryption(initial) + + // It works! First encrypted packet :) + client.write('client_to_server_handshake', {}) } client.on('server.client_handshake', startClientboundEncryption) -} -function createClientChain(client) { - const alice = client.ecdhKeyPair - const alicePEM = ec_pem(alice, curve) // https://github.com/nodejs/node/issues/15116#issuecomment-384790125 - const alicePEMPrivate = alicePEM.encodePrivateKey() - const x509 = writeX509PublicKey(alice.getPublicKey()) + client.on('client.server_handshake', startServerboundEncryption) - const token = JWT.sign({ - salt: toBase64(SALT), - signedToken: alice.getPublicKey('base64') - }, alicePEMPrivate, { algorithm: 'ES384', header: { x5u: x509 } }) + client.createClientChain = (mojangKey) => { + mojangKey = mojangKey || require('./constants').PUBLIC_KEY + const alice = client.ecdhKeyPair + const alicePEM = ec_pem(alice, curve) // https://github.com/nodejs/node/issues/15116#issuecomment-384790125 + const alicePEMPrivate = alicePEM.encodePrivateKey() - client.clientChain = token + const token = JWT.sign({ + identityPublicKey: mojangKey, + certificateAuthority: true + }, alicePEMPrivate, { algorithm: 'ES384', header: { x5u: client.clientX509 } }) + + client.clientIdentityChain = token + client.createClientUserChain(alicePEMPrivate) + } + + client.createClientUserChain = (privateKey) => { + let payload = { + ServerAddress: options.hostname, + ThirdPartyName: client.profile.name, + DeviceOS: client.session?.deviceOS || 1, + GameVersion: options.version || '1.16.201', + ClientRandomId: Date.now(), // TODO make biggeer + DeviceId: '2099de18-429a-465a-a49b-fc4710a17bb3', // TODO random + LanguageCode: 'en_GB', // TODO locale + AnimatedImageData: [], + PersonaPieces: [], + PieceTintColours: [], + SelfSignedId: '78eb38a6-950e-3ab9-b2cf-dd849e343701', + SkinId: '5eb65f73-af11-448e-82aa-1b7b165316ad.persona-e199672a8c1a87e0-0', + SkinData: 'AAAAAA==', + SkinResourcePatch: 'ewogICAiZ2VvbWV0cnkiIDogewogICAgICAiYW5pbWF0ZWRfMTI4eDEyOCIgOiAiZ2VvbWV0cnkuYW5pbWF0ZWRfMTI4eDEyOF9wZXJzb25hLWUxOTk2NzJhOGMxYTg3ZTAtMCIsCiAgICAgICJhbmltYXRlZF9mYWNlIiA6ICJnZW9tZXRyeS5hbmltYXRlZF9mYWNlX3BlcnNvbmEtZTE5OTY3MmE4YzFhODdlMC0wIiwKICAgICAgImRlZmF1bHQiIDogImdlb21ldHJ5LnBlcnNvbmFfZTE5OTY3MmE4YzFhODdlMC0wIgogICB9Cn0K', + SkinGeometryData: require('./geom'), + "SkinImageHeight": 1, + "SkinImageWidth": 1, + "ArmSize": "wide", + "CapeData": "", + "CapeId": "", + "CapeImageHeight": 0, + "CapeImageWidth": 0, + "CapeOnClassicSkin": false, + PlatformOfflineId: '', + PlatformOnlineId: '', //chat + // a bunch of meaningless junk + CurrentInputMode: 1, + DefaultInputMode: 1, + DeviceModel: '', + GuiScale: -1, + UIProfile: 0, + TenantId: '', + PremiumSkin: false, + PersonaSkin: false, + PieceTintColors: [], + SkinAnimationData: '', + ThirdPartyNameOnly: false, + "SkinColor": "#ffffcd96", + } + payload = require('./logPack.json') + const customPayload = options.userData || {} + payload = { ...payload, ...customPayload } + + client.clientUserChain = JWT.sign(payload, privateKey, + { algorithm: 'ES384', header: { x5u: client.clientX509 } }) + } } function toBase64(string) { return Buffer.from(string).toString('base64') -} +} function readX509PublicKey(key) { var reader = new Ber.Reader(Buffer.from(key, "base64")); diff --git a/src/client.js b/src/client.js index 8e20275..acba7a6 100644 --- a/src/client.js +++ b/src/client.js @@ -4,6 +4,9 @@ const { createDeserializer, createSerializer } = require('./transforms/serialize const { Encrypt } = require('./auth/encryption') const auth = require('./client/auth') const Options = require('./options') +const fs = require('fs') + +const log = console.log class Client extends Connection { constructor(options) { @@ -41,7 +44,7 @@ class Client extends Connection { if (this.raknet) return - this.raknet = new RakClient('localhost', 19132) + this.raknet = new RakClient('127.0.0.1', 19132) await this.raknet.connect() this.raknet.on('connecting', () => { @@ -61,16 +64,17 @@ class Client extends Connection { } sendLogin() { + this.createClientChain() const chain = [ - this.clientChain, // JWT we generated for auth + this.clientIdentityChain, // JWT we generated for auth ...this.accessToken // Mojang + Xbox JWT from auth ] const encodedChain = JSON.stringify({ chain }) const skinChain = JSON.stringify({}) - const bodyLength = skinChain.length + encodedChain.length + 8 + const bodyLength = this.clientUserChain.length + encodedChain.length + 8 console.log('Auth chain', chain) @@ -78,7 +82,7 @@ class Client extends Connection { protocol_version: this.options.version, payload_size: bodyLength, chain: encodedChain, - client_data: skinChain + client_data: this.clientUserChain }) } @@ -90,19 +94,31 @@ class Client extends Connection { this.emit('join') } + onDisconnectRequest(packet) { + // We're talking over UDP, so there is no connection to close, instead + // we stop communicating with the server + console.warn(`Server requested ${packet.hide_disconnect_reason ? 'silent disconnect' : 'disconnect'}: ${packet.message}`) + process.exit(1) + } + readPacket(packet) { console.log('packet', packet) - const des = this.server.deserializer.parsePacketBuffer(packet) - console.log('->', des) + const des = this.deserializer.parsePacketBuffer(packet) + console.info('->', des) switch (des.data.name) { - case 'login': - console.log(des) - this.onLogin(des) - return - case 'client_to_server_handshake': - this.onHandshake() + case 'server_to_client_handshake': + this.emit('client.server_handshake', des.data.params) + break + case 'disconnect': // Client kicked + this.onDisconnectRequest(des.data.params) + break + case 'crafting_data': + fs.writeFileSync('crafting.json', JSON.stringify(des.data.params, (k,v) => typeof v == 'bigint' ? v.toString() : v)) + break + case 'start_game': + fs.writeFileSync('start_game.json', JSON.stringify(des.data.params, (k,v) => typeof v == 'bigint' ? v.toString() : v)) default: - console.log('ignoring, unhandled') + console.log('Sending to listeners') } this.emit(des.data.name, des.data.params) diff --git a/src/client/auth.js b/src/client/auth.js index 696cf30..bb83897 100644 --- a/src/client/auth.js +++ b/src/client/auth.js @@ -63,8 +63,8 @@ async function authenticateDeviceCode (client, options) { try { const flow = new MsAuthFlow(options.username, options.profilesFolder, options.onMsaCode) - const chain = await flow.getMinecraftToken(client.clientChain) - + const chain = await flow.getMinecraftToken(client.clientX509) + // console.log('Chain', chain) await postAuthenticate(client, options, chain) } catch (err) { console.error(err) diff --git a/src/client/authFlow.js b/src/client/authFlow.js index 8caab8d..1c0325f 100644 --- a/src/client/authFlow.js +++ b/src/client/authFlow.js @@ -109,7 +109,9 @@ class MsAuthFlow { } async getMinecraftToken (publicKey) { - if (await this.mca.verifyTokens()) { + // TODO: Fix cache, in order to do cache we also need to cache the ECDH keys so disable it + // is this even a good idea to cache? + if (await this.mca.verifyTokens() && false) { debug('[mc] Using existing tokens') return this.mca.getCachedAccessToken().chain } else { @@ -118,7 +120,8 @@ class MsAuthFlow { return await retry(async () => { const xsts = await this.getXboxToken() debug('[xbl] xsts data', xsts) - return this.mca.getAccessToken(publicKey, xsts).chain + const token = await this.mca.getAccessToken(publicKey, xsts) + return token.chain }, () => { this.xbl.forceRefresh = true }, 2) } } diff --git a/src/client/tokens.js b/src/client/tokens.js index aeae166..1959211 100644 --- a/src/client/tokens.js +++ b/src/client/tokens.js @@ -268,7 +268,7 @@ class MinecraftTokenManager { } async getAccessToken(clientPublicKey, xsts) { - debug('[mc] authing to minecraft', xsts) + debug('[mc] authing to minecraft', clientPublicKey, xsts) const getFetchOptions = { headers: { 'Content-Type': 'application/json', diff --git a/src/clientTest.js b/src/clientTest.js index 4c4eb43..d0ba45f 100644 --- a/src/clientTest.js +++ b/src/clientTest.js @@ -1,10 +1,19 @@ process.env.DEBUG = 'minecraft-protocol raknet' const { Client } = require('./client') +// console.log = () => async function test() { const client = new Client({ - + hostname: '127.0.0.1', + port: 19132 + }) + + client.once('resource_packs_info', (packet) => { + client.write('resource_pack_client_response', { + response_status: 'completed', + resourcepackids: [] + }) }) } diff --git a/src/connection.js b/src/connection.js index 3712a0f..e6eef5a 100644 --- a/src/connection.js +++ b/src/connection.js @@ -8,7 +8,7 @@ const EncapsulatedPacket = require('@jsprismarine/raknet/protocol/encapsulated_p class Connection extends EventEmitter { startEncryption(iv) { this.encryptionEnabled = true - + console.log('Started encryption', this.sharedSecret, iv) this.decrypt = cipher.createDecryptor(this, iv) this.encrypt = cipher.createEncryptor(this, iv) } @@ -74,7 +74,7 @@ class Connection extends EventEmitter { } onDecryptedPacket = (buf) => { - console.log('Decrypted', buf) + console.log('🟢 Decrypted', buf) const stream = new BinaryStream(buf) const packets = BatchPacket.getPackets(stream) diff --git a/src/datatypes/compiler-minecraft.js b/src/datatypes/compiler-minecraft.js index f8fa767..7dc6ea4 100644 --- a/src/datatypes/compiler-minecraft.js +++ b/src/datatypes/compiler-minecraft.js @@ -3,7 +3,7 @@ const minecraft = require('./minecraft') module.exports = { Read: { - UUID: ['native', (buffer, offset) => { + uuid: ['native', (buffer, offset) => { return { value: UUID.stringify(buffer.slice(offset, 16 + offset)), size: 16 @@ -18,7 +18,7 @@ module.exports = { nbt: ['native', minecraft.nbt[0]] }, Write: { - UUID: ['native', (value, buffer, offset) => { + uuid: ['native', (value, buffer, offset) => { const buf = UUID.parse(value) buf.copy(buffer, offset) return offset + 16 @@ -30,7 +30,7 @@ module.exports = { nbt: ['native', minecraft.nbt[1]] }, SizeOf: { - UUID: ['native', 16], + uuid: ['native', 16], restBuffer: ['native', (value) => { return value.length }], diff --git a/src/serverTest.js b/src/serverTest.js index a02ac35..0d733f6 100644 --- a/src/serverTest.js +++ b/src/serverTest.js @@ -6,7 +6,7 @@ const fs = require('fs') let server = new Server({ }) -server.create('0.0.0.0', 19130) +server.create('0.0.0.0', 19132) let ran = false diff --git a/src/transforms/encryption.js b/src/transforms/encryption.js index 7aaee6b..f9cf293 100644 --- a/src/transforms/encryption.js +++ b/src/transforms/encryption.js @@ -119,28 +119,46 @@ function createDecryptor(client, iv) { const verifyChecksum = new Transform({ // verify checksum transform(chunk, encoding, cb) { - console.log('Decryptor: checking checksum', chunk) + console.log('Decryptor: checking checksum', client.receiveCounter, chunk) const packet = chunk.slice(0, chunk.length - 8); const checksum = chunk.slice(chunk.length - 8); const computedCheckSum = computeCheckSum(packet, client.receiveCounter, client.secretKeyBytes) + // console.log(computedCheckSum2, computedCheckSum3) console.assert(checksum.toString("hex") == computedCheckSum.toString("hex"), 'checksum mismatch') client.receiveCounter++ - if (checksum.toString("hex") == computedCheckSum.toString("hex")) { + // if (checksum.toString("hex") == computedCheckSum.toString("hex")) { this.push(packet) - } else { - throw Error(`Checksum mismatch ${checksum.toString("hex")} != ${computedCheckSum.toString("hex")}`) - } + // console.log('🔵 Decriphered', checksum) + + // const inflated = Zlib.inflateRawSync(chunk, { + // chunkSize: 1024 * 1024 * 2 + // }) + // console.log('🔵 Inflated') + // client.onDecryptedPacket(inflated) + // } else { + // // console.log('🔴 Not OK') + // throw Error(`Checksum mismatch ${checksum.toString("hex")} != ${computedCheckSum.toString("hex")}`) + // } cb() } }) const inflator = new Transform({ transform(chunk, enc, cb) { - Zlib.inflateRaw(chunk, { chunkSize: 1024 * 1024 * 2 }, (err, buf) => { - if (err) throw err - this.push(buf) - cb() + console.log('🔵 Inflating') + const inflated = Zlib.inflateRawSync(chunk, { + chunkSize: 1024 * 1024 * 2 }) + console.log('🔵 Inflated') + this.push(inflated) + cb() + + // Zlib.inflateRaw(chunk, { chunkSize: 1024 * 1024 * 2 }, (err, buf) => { + // console.log('🔵 INF') + // if (err) throw err + // this.push(buf) + // cb() + // }) } }) @@ -148,11 +166,24 @@ function createDecryptor(client, iv) { .pipe(inflator) // .pipe(Zlib.createInflateRaw({ chunkSize: 1024 * 1024 * 2 })) .on('data', (...args) => client.onDecryptedPacket(...args)) - .on('end', () => console.log('Decryptor: finish pipeline')) + // .on('end', () => console.log('Decryptor: finish pipeline')) + // Not sure why, but sending two packets to the decryption pipe before + // the other is completed breaks the checksum check. + // TODO: Refactor the logic here to be async so we can await a promise + // queue + let decQ = [] + setInterval(() => { + if (decQ.length) { + let pak = decQ.shift() + console.log('🟡 DECRYPTING', pak) + client.decipher.write(pak) + } + }, 500) return (blob) => { - client.decipher.write(blob) + decQ.push(blob) + // client.decipher.write(blob) } }