├── docs ├── .nojekyll ├── _sidebar.md ├── index.html ├── FAQ.md ├── chat.md └── README.md ├── .npmrc ├── .npmignore ├── .github ├── FUNDING.yml ├── helper │ ├── package.json │ └── updator.js ├── dependabot.yml ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── workflows │ ├── commands.yml │ ├── npm-publish.yml │ ├── update-from-minecraft-data.yml │ └── ci.yml └── CROSS_REPO_TRIGGER.md ├── .gitpod.yml ├── examples ├── client_electron │ ├── README.md │ ├── package.json │ ├── Window.js │ ├── renderer │ │ ├── index.html │ │ └── index.js │ └── main.js ├── ipc │ ├── package.json │ └── ipc_server.js ├── server │ ├── package.json │ └── server.js ├── client_auto │ ├── package.json │ └── client_auto.js ├── client_echo │ ├── package.json │ └── client_echo.js ├── client_channel │ ├── package.json │ └── client_channel.js ├── client_realms │ ├── package.json │ └── client_realms.js ├── server_channel │ ├── package.json │ └── server_channel.js ├── client_custom_channel │ ├── package.json │ └── client_custom_channel.js ├── client_custom_packets │ ├── package.json │ └── client_custom_packets.js ├── client_microsoft_auth │ ├── package.json │ ├── client_msal_auth.js │ └── client_microsoft_auth.js ├── server_custom_channel │ ├── package.json │ └── server_custom_channel.js ├── server_helloworld │ ├── package.json │ └── server_helloworld.js ├── proxy │ ├── package.json │ ├── README.md │ └── proxy.js ├── client_chat │ ├── package.json │ └── client_chat.js ├── client_http_proxy │ ├── package.json │ └── client_http_proxy.js ├── client_socks_proxy │ ├── package.json │ ├── README.md │ └── client_socks_proxy.js ├── compiler_parse_buffer │ ├── package.json │ └── index.js ├── server_world │ ├── package.json │ └── mc.js ├── client_custom_auth │ ├── package.json │ └── client_custom_auth.js └── server_ping │ └── ping.js ├── .gitignore ├── src ├── states.js ├── client │ ├── compress.js │ ├── keepalive.js │ ├── versionChecking.js │ ├── setProtocol.js │ ├── tcp_dns.js │ ├── autoVersion.js │ ├── microsoftAuth.js │ ├── encrypt.js │ ├── play.js │ ├── pluginChannels.js │ └── mojangAuth.js ├── version.js ├── browser.js ├── index.js ├── datatypes │ ├── checksums.js │ ├── uuid.js │ ├── minecraft.js │ └── compiler-minecraft.js ├── transforms │ ├── binaryStream.js │ ├── encryption.js │ ├── framing.js │ ├── compression.js │ └── serializer.js ├── server │ ├── constants.js │ ├── keepalive.js │ ├── handshake.js │ ├── ping.js │ ├── login.js │ └── chat.js ├── createServer.js ├── ping.js ├── server.js ├── createClient.js ├── client.js └── index.d.ts ├── test ├── common │ ├── util.js │ └── clientHelpers.js ├── docTest.js ├── benchmark.js └── cyclePacketTest.js ├── LICENSE └── package.json /docs/.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | test/npm-debug.log 3 | test/server 4 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | tasks: 2 | - command: npm install && sdk install java 3 | -------------------------------------------------------------------------------- /examples/client_electron/README.md: -------------------------------------------------------------------------------- 1 | # client_electron 2 | 3 | * npm install 4 | * npm start 5 | -------------------------------------------------------------------------------- /.github/helper/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "gh-helpers": "^1.0.0" 4 | } 5 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | test/npm-debug.log 3 | test/server_* 4 | package-lock.json 5 | versions/ 6 | src/client/*.json 7 | test_* -------------------------------------------------------------------------------- /examples/ipc/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-minecraft-protocol-example", 3 | "version": "0.0.0", 4 | "private": true, 5 | "dependencies": { 6 | }, 7 | "description": "A node-minecraft-protocol example" 8 | } -------------------------------------------------------------------------------- /examples/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-minecraft-protocol-example", 3 | "version": "0.0.0", 4 | "private": true, 5 | "dependencies": { 6 | }, 7 | "description": "A node-minecraft-protocol example" 8 | } 9 | -------------------------------------------------------------------------------- /examples/client_auto/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-minecraft-protocol-example", 3 | "version": "0.0.0", 4 | "private": true, 5 | "dependencies": { 6 | }, 7 | "description": "A node-minecraft-protocol example" 8 | } 9 | -------------------------------------------------------------------------------- /examples/client_echo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-minecraft-protocol-example", 3 | "version": "0.0.0", 4 | "private": true, 5 | "dependencies": { 6 | }, 7 | "description": "A node-minecraft-protocol example" 8 | } 9 | -------------------------------------------------------------------------------- /docs/_sidebar.md: -------------------------------------------------------------------------------- 1 | - [Introduction](/) 2 | - [API](API.md) 3 | - [FAQ](FAQ.md) 4 | - [History](HISTORY.md) 5 | - [Protocol Documentation](http://prismarinejs.github.io/minecraft-data?d=protocol) 6 | - [Wiki.vg](https://wiki.vg/Protocol) 7 | -------------------------------------------------------------------------------- /examples/client_channel/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-minecraft-protocol-example", 3 | "version": "0.0.0", 4 | "private": true, 5 | "dependencies": { 6 | }, 7 | "description": "A node-minecraft-protocol example" 8 | } 9 | -------------------------------------------------------------------------------- /examples/client_realms/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-minecraft-protocol-example", 3 | "version": "0.0.0", 4 | "private": true, 5 | "dependencies": { 6 | }, 7 | "description": "A node-minecraft-protocol example" 8 | } 9 | -------------------------------------------------------------------------------- /examples/server_channel/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-minecraft-protocol-example", 3 | "version": "0.0.0", 4 | "private": true, 5 | "dependencies": { 6 | }, 7 | "description": "A node-minecraft-protocol example" 8 | } 9 | -------------------------------------------------------------------------------- /examples/client_custom_channel/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-minecraft-protocol-example", 3 | "version": "0.0.0", 4 | "private": true, 5 | "dependencies": { 6 | }, 7 | "description": "A node-minecraft-protocol example" 8 | } 9 | -------------------------------------------------------------------------------- /examples/client_custom_packets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-minecraft-protocol-example", 3 | "version": "0.0.0", 4 | "private": true, 5 | "dependencies": { 6 | }, 7 | "description": "A node-minecraft-protocol example" 8 | } 9 | -------------------------------------------------------------------------------- /examples/client_microsoft_auth/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-minecraft-protocol-example", 3 | "version": "0.0.0", 4 | "private": true, 5 | "dependencies": { 6 | }, 7 | "description": "A node-minecraft-protocol example" 8 | } 9 | -------------------------------------------------------------------------------- /examples/server_custom_channel/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-minecraft-protocol-example", 3 | "version": "0.0.0", 4 | "private": true, 5 | "dependencies": { 6 | }, 7 | "description": "A node-minecraft-protocol example" 8 | } 9 | -------------------------------------------------------------------------------- /examples/server_helloworld/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-minecraft-protocol-example", 3 | "version": "0.0.0", 4 | "private": true, 5 | "dependencies": { 6 | }, 7 | "description": "A node-minecraft-protocol example" 8 | } 9 | -------------------------------------------------------------------------------- /src/states.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const states = { 4 | HANDSHAKING: 'handshaking', 5 | STATUS: 'status', 6 | LOGIN: 'login', 7 | CONFIGURATION: 'configuration', 8 | PLAY: 'play' 9 | } 10 | 11 | module.exports = states 12 | -------------------------------------------------------------------------------- /examples/proxy/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-minecraft-protocol-example", 3 | "version": "0.0.0", 4 | "private": true, 5 | "dependencies": { 6 | "buffer-equal": "0.0.2" 7 | }, 8 | "description": "A node-minecraft-protocol example" 9 | } 10 | -------------------------------------------------------------------------------- /examples/client_chat/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-minecraft-protocol-example", 3 | "version": "0.0.0", 4 | "private": true, 5 | "dependencies": { 6 | "prismarine-chat": "^1.0.2" 7 | }, 8 | "description": "A node-minecraft-protocol example" 9 | } 10 | -------------------------------------------------------------------------------- /examples/client_http_proxy/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-minecraft-protocol-example", 3 | "version": "0.0.0", 4 | "private": true, 5 | "dependencies": { 6 | "proxy-agent": "^3.1.1" 7 | }, 8 | "description": "A node-minecraft-protocol example" 9 | } 10 | -------------------------------------------------------------------------------- /examples/client_socks_proxy/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-minecraft-protocol-example", 3 | "version": "0.0.0", 4 | "private": true, 5 | "dependencies": { 6 | "proxy-agent": "^3.1.1", 7 | "socks": "^2.3.3" 8 | }, 9 | "description": "A node-minecraft-protocol example" 10 | } 11 | -------------------------------------------------------------------------------- /examples/compiler_parse_buffer/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-minecraft-protocol-example", 3 | "version": "0.0.0", 4 | "private": true, 5 | "dependencies": { 6 | "minecraft-packets": "^1.1.5" 7 | }, 8 | "description": "A node-minecraft-protocol example" 9 | } 10 | -------------------------------------------------------------------------------- /examples/server_world/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-minecraft-protocol-example", 3 | "version": "1.0.0", 4 | "private": true, 5 | "dependencies": { 6 | "prismarine-chunk": "^1.7.0", 7 | "vec3": "^0.1.3" 8 | }, 9 | "description": "A node-minecraft-protocol example", 10 | "author": "Oscar Beaumont" 11 | } 12 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | ignore: 9 | - dependency-name: node-rsa 10 | versions: 11 | - ">= 1.a, < 2" 12 | - dependency-name: "@types/node" 13 | versions: 14 | - 15.0.0 15 | -------------------------------------------------------------------------------- /examples/client_custom_auth/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-minecraft-protocol-example-client-custom-auth", 3 | "version": "0.0.0", 4 | "description": "A node-minecraft-protocol example", 5 | "main": "client_custom_auth.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "" 10 | } 11 | -------------------------------------------------------------------------------- /test/common/util.js: -------------------------------------------------------------------------------- 1 | const net = require('net') 2 | 3 | const getPort = () => new Promise(resolve => { 4 | const server = net.createServer() 5 | server.listen(0, '127.0.0.1') 6 | server.on('listening', () => { 7 | const { port } = server.address() 8 | server.close(() => resolve(port)) 9 | }) 10 | }) 11 | 12 | module.exports = { getPort } 13 | -------------------------------------------------------------------------------- /src/client/compress.js: -------------------------------------------------------------------------------- 1 | module.exports = function (client, options) { 2 | client.once('compress', onCompressionRequest) 3 | client.on('set_compression', onCompressionRequest) 4 | 5 | function onCompressionRequest (packet) { 6 | client.compressionThreshold = packet.threshold 7 | } 8 | // TODO: refactor with transforms/compression.js -- enable it here 9 | } 10 | -------------------------------------------------------------------------------- /src/version.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = { 4 | defaultVersion: '1.21.8', 5 | supportedVersions: ['1.7', '1.8.8', '1.9.4', '1.10.2', '1.11.2', '1.12.2', '1.13.2', '1.14.4', '1.15.2', '1.16.5', '1.17.1', '1.18.2', '1.19', '1.19.2', '1.19.3', '1.19.4', '1.20', '1.20.1', '1.20.2', '1.20.4', '1.20.6', '1.21.1', '1.21.3', '1.21.4', '1.21.5', '1.21.6', '1.21.8'] 6 | } 7 | -------------------------------------------------------------------------------- /examples/client_electron/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-minecraft-protocol-example", 3 | "version": "0.0.0", 4 | "private": true, 5 | "description": "A node-minecraft-protocol example", 6 | "main": "main.js", 7 | "scripts": { 8 | "start": "electron ." 9 | }, 10 | "dependencies": { 11 | "electron": "^11.5.0", 12 | "electron-reload": "^1.5.0" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/browser.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Client = require('./client') 4 | const Server = require('./server') 5 | const serializer = require('./transforms/serializer') 6 | 7 | module.exports = { 8 | Client, 9 | Server, 10 | states: require('./states'), 11 | createSerializer: serializer.createSerializer, 12 | createDeserializer: serializer.createDeserializer, 13 | supportedVersions: require('./version').supportedVersions 14 | } 15 | -------------------------------------------------------------------------------- /test/docTest.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | 3 | const mc = require('../') 4 | const fs = require('fs') 5 | const assert = require('assert') 6 | const path = require('path') 7 | 8 | const readmeContent = fs.readFileSync(path.join(__dirname, '/../docs/README.md'), { encoding: 'utf8', flag: 'r' }) 9 | 10 | for (const supportedVersion of mc.supportedVersions) { 11 | describe('doc ' + supportedVersion + 'v', function () { 12 | it('mentions the supported version in the readme', () => { 13 | assert.ok(readmeContent.includes(supportedVersion), `${supportedVersion} should be mentionned in the README.md but it is not`) 14 | }) 15 | }) 16 | } 17 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Client = require('./client') 4 | const Server = require('./server') 5 | const serializer = require('./transforms/serializer') 6 | const createClient = require('./createClient') 7 | const createServer = require('./createServer') 8 | 9 | module.exports = { 10 | createClient, 11 | createServer, 12 | Client, 13 | Server, 14 | states: require('./states'), 15 | createSerializer: serializer.createSerializer, 16 | createDeserializer: serializer.createDeserializer, 17 | ping: require('./ping'), 18 | supportedVersions: require('./version').supportedVersions, 19 | defaultVersion: require('./version').defaultVersion 20 | } 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /src/datatypes/checksums.js: -------------------------------------------------------------------------------- 1 | // Compute chat checksum using Java's Arrays.hashCode algorithm to match vanilla client 2 | function computeChatChecksum (lastSeenMessages) { 3 | if (!lastSeenMessages || lastSeenMessages.length === 0) return 1 4 | 5 | let checksum = 1 6 | for (const message of lastSeenMessages) { 7 | if (message.signature) { 8 | let sigHash = 1 9 | for (let i = 0; i < message.signature.length; i++) { 10 | sigHash = (31 * sigHash + message.signature[i]) & 0xffffffff 11 | } 12 | checksum = (31 * checksum + sigHash) & 0xffffffff 13 | } 14 | } 15 | // Convert to byte 16 | const result = checksum & 0xff 17 | return result === 0 ? 1 : result 18 | } 19 | 20 | module.exports = { computeChatChecksum } 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: possible bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | [ ] The [FAQ](https://github.com/PrismarineJS/node-minecraft-protocol/blob/master/docs/FAQ.md) doesn't contain a resolution to my issue 11 | 12 | ## Versions 13 | - minecraft-protocol: #.#.# 14 | - server: vanilla/spigot/paper #.#.# 15 | - node: #.#.# 16 | 17 | ## Detailed description of a problem 18 | A clear and concise description of what the problem is. 19 | 20 | ## Current code 21 | ```js 22 | 23 | ``` 24 | 25 | ## Expected behavior 26 | A clear and concise description of what you expected to happen. 27 | 28 | ## Additional context 29 | Add any other context about the problem here. 30 | -------------------------------------------------------------------------------- /src/client/keepalive.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = function (client, options) { 4 | const keepAlive = options.keepAlive == null ? true : options.keepAlive 5 | if (!keepAlive) return 6 | 7 | const checkTimeoutInterval = options.checkTimeoutInterval || 30 * 1000 8 | 9 | client.on('keep_alive', onKeepAlive) 10 | 11 | let timeout = null 12 | 13 | client.on('end', () => clearTimeout(timeout)) 14 | 15 | function onKeepAlive (packet) { 16 | if (timeout) { clearTimeout(timeout) } 17 | timeout = setTimeout(() => { 18 | client.emit('error', new Error(`client timed out after ${checkTimeoutInterval} milliseconds`)) 19 | client.end('keepAliveError') 20 | }, checkTimeoutInterval) 21 | client.write('keep_alive', { 22 | keepAliveId: packet.keepAliveId 23 | }) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/transforms/binaryStream.js: -------------------------------------------------------------------------------- 1 | const types = {} 2 | Object.assign(types, require('protodef').types) 3 | Object.assign(types, require('../datatypes/minecraft')) 4 | 5 | function concat (...args) { 6 | let allocLen = 0 7 | for (let i = 0; i < args.length; i += 2) { 8 | const type = args[i] 9 | const value = args[i + 1] 10 | const [,, s] = types[type] 11 | allocLen += typeof s === 'number' ? s : s(value, {}) 12 | } 13 | const buffer = Buffer.alloc(allocLen) 14 | let offset = 0 15 | for (let i = 0; i < args.length; i += 2) { 16 | const type = args[i] 17 | const value = args[i + 1] 18 | offset = types[type][1](value, buffer, offset, {}) 19 | } 20 | return buffer 21 | } 22 | 23 | // concat('i32', 22, 'i64', 2n) => 24 | module.exports = { concat } 25 | -------------------------------------------------------------------------------- /examples/client_electron/Window.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { BrowserWindow } = require('electron') 4 | 5 | // default window settings 6 | const defaultProps = { 7 | width: 500, 8 | height: 800, 9 | show: false, 10 | 11 | // update for electron V5+ 12 | webPreferences: { 13 | nodeIntegration: true 14 | } 15 | } 16 | 17 | class Window extends BrowserWindow { 18 | constructor ({ file, ...windowSettings }) { 19 | // calls new BrowserWindow with these props 20 | super({ ...defaultProps, ...windowSettings }) 21 | 22 | // load the html and open devtools 23 | this.loadFile(file) 24 | // this.webContents.openDevTools() 25 | 26 | // gracefully show when ready to prevent flickering 27 | this.once('ready-to-show', () => { 28 | this.show() 29 | }) 30 | } 31 | } 32 | 33 | module.exports = Window 34 | -------------------------------------------------------------------------------- /src/datatypes/uuid.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto') 2 | const UUID = require('uuid-1345') 3 | 4 | // https://github.com/openjdk-mirror/jdk7u-jdk/blob/f4d80957e89a19a29bb9f9807d2a28351ed7f7df/src/share/classes/java/util/UUID.java#L163 5 | function javaUUID (s) { 6 | const hash = crypto.createHash('md5') 7 | hash.update(s, 'utf8') 8 | const buffer = hash.digest() 9 | buffer[6] = (buffer[6] & 0x0f) | 0x30 10 | buffer[8] = (buffer[8] & 0x3f) | 0x80 11 | return buffer 12 | } 13 | 14 | function nameToMcOfflineUUID (name) { 15 | return (new UUID(javaUUID('OfflinePlayer:' + name))).toString() 16 | } 17 | 18 | function fromIntArray (arr) { 19 | const buf = Buffer.alloc(16) 20 | arr.forEach((num, index) => { buf.writeInt32BE(num, index * 4) }) 21 | return buf.toString('hex') 22 | } 23 | 24 | module.exports = { nameToMcOfflineUUID, fromIntArray } 25 | -------------------------------------------------------------------------------- /src/server/constants.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | mojangPublicKeyPem: '-----BEGIN PUBLIC KEY-----\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAylB4B6m5lz7jwrcFz6Fd\n/fnfUhcvlxsTSn5kIK/2aGG1C3kMy4VjhwlxF6BFUSnfxhNswPjh3ZitkBxEAFY2\n5uzkJFRwHwVA9mdwjashXILtR6OqdLXXFVyUPIURLOSWqGNBtb08EN5fMnG8iFLg\nEJIBMxs9BvF3s3/FhuHyPKiVTZmXY0WY4ZyYqvoKR+XjaTRPPvBsDa4WI2u1zxXM\neHlodT3lnCzVvyOYBLXL6CJgByuOxccJ8hnXfF9yY4F0aeL080Jz/3+EBNG8RO4B\nyhtBf4Ny8NQ6stWsjfeUIvH7bU/4zCYcYOq4WrInXHqS8qruDmIl7P5XXGcabuzQ\nstPf/h2CRAUpP/PlHXcMlvewjmGU6MfDK+lifScNYwjPxRo4nKTGFZf/0aqHCh/E\nAsQyLKrOIYRE0lDG3bzBh8ogIMLAugsAfBb6M3mqCqKaTMAf/VAjh5FFJnjS+7bE\n+bZEV0qwax1CEoPPJL1fIQjOS8zj086gjpGRCtSy9+bTPTfTR/SJ+VUB5G2IeCIt\nkNHpJX2ygojFZ9n5Fnj7R9ZnOM+L8nyIjPu3aePvtcrXlyLhH/hvOfIOjPxOlqW+\nO5QwSFP4OEcyLAUgDdUgyW36Z5mB285uKW/ighzZsOTevVUG2QwDItObIV6i8RCx\nFbN2oDHyPaO5j1tTaBNyVt8CAwEAAQ==\n-----END PUBLIC KEY-----' 3 | } 4 | -------------------------------------------------------------------------------- /examples/compiler_parse_buffer/index.js: -------------------------------------------------------------------------------- 1 | const { createSerializer, createDeserializer, states } = require('minecraft-protocol') 2 | const mcPackets = require('minecraft-packets') 3 | 4 | const serializer = createSerializer({ state: states.PLAY, version: '1.16.5', isServer: true }) 5 | const deserializer = createDeserializer({ state: states.PLAY, version: '1.16.5' }) 6 | 7 | function convertBufferToObject (buffer) { 8 | return deserializer.parsePacketBuffer(buffer) 9 | } 10 | 11 | function convertObjectToBuffer (object) { 12 | return serializer.createPacketBuffer(object) 13 | } 14 | 15 | const buffer = mcPackets.pc['1.16.5']['from-server'].abilities[0].raw 16 | const parsed = convertBufferToObject(buffer).data 17 | const parsedBuffer = convertObjectToBuffer(parsed) 18 | console.log(buffer) 19 | console.log(parsedBuffer) 20 | console.log(buffer.equals(parsedBuffer)) 21 | -------------------------------------------------------------------------------- /examples/client_realms/client_realms.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const mc = require('minecraft-protocol') 4 | 5 | const [,, username, realmName] = process.argv 6 | if (!realmName) { 7 | console.log('Usage : node client_realms.js ') 8 | process.exit(1) 9 | } 10 | 11 | const client = mc.createClient({ 12 | realms: { 13 | // realmId: '1234567', // Connect the client to a Realm using the Realms ID 14 | pickRealm: (realms) => realms.find(e => e.name === realmName) // Connect the client to a Realm using a function that returns a Realm 15 | }, 16 | username, 17 | auth: 'microsoft' // This option must be present and set to 'microsoft' to join a Realm. 18 | }) 19 | 20 | client.on('connect', function () { 21 | console.info('connected') 22 | }) 23 | client.on('disconnect', function (packet) { 24 | console.log('disconnected: ' + packet.reason) 25 | }) 26 | -------------------------------------------------------------------------------- /examples/client_electron/renderer/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | client electron 7 | 8 | 9 | 10 | 11 |
12 |

Host:

13 |

Port:

14 |

Account Type:

15 |

Username:

16 |

Password:

17 |
18 | 19 |
20 |
Not connected

21 | Message:
22 |
23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /examples/client_custom_channel/client_custom_channel.js: -------------------------------------------------------------------------------- 1 | const mc = require('minecraft-protocol') 2 | 3 | if (process.argv.length < 4 || process.argv.length > 6) { 4 | console.log('Usage : node client_channel.js [] []') 5 | process.exit(1) 6 | } 7 | 8 | const client = mc.createClient({ 9 | host: process.argv[2], 10 | port: parseInt(process.argv[3]), 11 | username: process.argv[4] ? process.argv[4] : 'test', 12 | password: process.argv[5], 13 | version: false 14 | }) 15 | 16 | client.on('login', onlogin) 17 | client.on('error', console.log) 18 | 19 | function onlogin () { 20 | client.registerChannel('node-minecraft-protocol:custom_channel_one', ['string', []], true) 21 | client.registerChannel('node-minecraft-protocol:custom_channel_two', ['string', []], true) 22 | client.writeChannel('node-minecraft-protocol:custom_channel_one', 'hello from the client') 23 | client.on('node-minecraft-protocol:custom_channel_two', console.log) 24 | } 25 | -------------------------------------------------------------------------------- /.github/workflows/commands.yml: -------------------------------------------------------------------------------- 1 | name: Repo Commands 2 | 3 | on: 4 | issue_comment: # Handle comment commands 5 | types: [created] 6 | pull_request: # Handle renamed PRs 7 | types: [edited] 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | comment-trigger: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Check out repository 16 | uses: actions/checkout@v3 17 | - name: Run command handlers 18 | uses: PrismarineJS/prismarine-repo-actions@master 19 | with: 20 | # NOTE: You must specify a Personal Access Token (PAT) with repo access here. While you can use the default GITHUB_TOKEN, actions taken with it will not trigger other actions, so if you have a CI workflow, commits created by this action will not trigger it. 21 | token: ${{ secrets.PAT_PASSWORD }} 22 | # See `Options` section below for more info on these options 23 | install-command: npm install 24 | /fixlint.fix-command: npm run fix -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | node-minecraft-protocol - Parse and serialize minecraft packets, plus authentication and encryption. 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /examples/client_channel/client_channel.js: -------------------------------------------------------------------------------- 1 | const mc = require('minecraft-protocol') 2 | 3 | if (process.argv.length < 4 || process.argv.length > 6) { 4 | console.log('Usage : node client_channel.js [] []') 5 | process.exit(1) 6 | } 7 | 8 | function getBrandChannelName () { 9 | const mcData = require('minecraft-data')(client.version) 10 | if (mcData.supportFeature('customChannelIdentifier')) { 11 | return 'minecraft:brand' // 1.13+ 12 | } 13 | return 'MC|Brand' 14 | } 15 | 16 | const client = mc.createClient({ 17 | version: false, 18 | host: process.argv[2], 19 | port: parseInt(process.argv[3]), 20 | username: process.argv[4] ? process.argv[4] : 'test', 21 | password: process.argv[5] 22 | }) 23 | 24 | client.on('error', console.log) 25 | 26 | client.on('login', function () { 27 | const brandChannel = getBrandChannelName() 28 | client.registerChannel(brandChannel, ['string', []]) 29 | client.on(brandChannel, console.log) 30 | client.writeChannel(brandChannel, 'vanilla') 31 | }) 32 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | name: npm-publish 2 | on: 3 | push: 4 | branches: 5 | - master # Change this to your default branch 6 | jobs: 7 | npm-publish: 8 | name: npm-publish 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout repository 12 | uses: actions/checkout@master 13 | - name: Set up Node.js 14 | uses: actions/setup-node@master 15 | with: 16 | node-version: 22.0.0 17 | - id: publish 18 | uses: JS-DevTools/npm-publish@v1 19 | with: 20 | token: ${{ secrets.NPM_AUTH_TOKEN }} 21 | - name: Create Release 22 | if: steps.publish.outputs.type != 'none' 23 | id: create_release 24 | uses: actions/create-release@v1 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | with: 28 | tag_name: ${{ steps.publish.outputs.version }} 29 | release_name: Release ${{ steps.publish.outputs.version }} 30 | body: ${{ steps.publish.outputs.version }} 31 | draft: false 32 | prerelease: false -------------------------------------------------------------------------------- /examples/client_auto/client_auto.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const mc = require('minecraft-protocol') 4 | 5 | if (process.argv.length < 4 || process.argv.length > 6) { 6 | console.log('Usage : node echo.js [] []') 7 | process.exit(1) 8 | } 9 | 10 | const client = mc.createClient({ 11 | version: false, 12 | host: process.argv[2], 13 | port: parseInt(process.argv[3]), 14 | username: process.argv[4] ? process.argv[4] : 'echo', 15 | password: process.argv[5] 16 | }) 17 | 18 | client.on('connect', function () { 19 | console.info('connected') 20 | }) 21 | client.on('disconnect', function (packet) { 22 | console.log('disconnected: ' + packet.reason) 23 | }) 24 | client.on('chat', function (packet) { 25 | const jsonMsg = JSON.parse(packet.message) 26 | if (jsonMsg.translate === 'chat.type.announcement' || jsonMsg.translate === 'chat.type.text') { 27 | const username = jsonMsg.with[0].text 28 | const msg = jsonMsg.with[1] 29 | if (username === client.username) return 30 | client.write('chat', { message: msg }) 31 | } 32 | }) 33 | -------------------------------------------------------------------------------- /examples/server_channel/server_channel.js: -------------------------------------------------------------------------------- 1 | const mc = require('minecraft-protocol') 2 | 3 | const server = mc.createServer({ 4 | 'online-mode': false, // optional 5 | encryption: false, // optional 6 | version: '1.18.2' 7 | }) 8 | const mcData = require('minecraft-data')(server.version) 9 | const loginPacket = mcData.loginPacket 10 | 11 | server.on('playerJoin', function (client) { 12 | client.registerChannel('minecraft:brand', ['string', []]) 13 | client.on('minecraft:brand', console.log) 14 | 15 | client.write('login', { 16 | ...loginPacket, 17 | enforceSecureChat: false, 18 | entityId: client.id, 19 | isHardcore: false, 20 | gameMode: 0, 21 | previousGameMode: 1, 22 | worldName: 'minecraft:overworld', 23 | hashedSeed: [0, 0], 24 | maxPlayers: server.maxPlayers, 25 | viewDistance: 10, 26 | reducedDebugInfo: false, 27 | enableRespawnScreen: true, 28 | isDebug: false, 29 | isFlat: false 30 | }) 31 | client.write('position', { 32 | x: 0, 33 | y: 1.62, 34 | z: 0, 35 | yaw: 0, 36 | pitch: 0, 37 | flags: 0x00 38 | }) 39 | client.writeChannel('minecraft:brand', 'vanilla') 40 | }) 41 | -------------------------------------------------------------------------------- /.github/workflows/update-from-minecraft-data.yml: -------------------------------------------------------------------------------- 1 | name: Update from minecraft-data 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | new_mc_version: 7 | description: New minecraft version number 8 | required: true 9 | type: string 10 | mcdata_branch: 11 | description: minecraft-data branch for this version 12 | required: true 13 | type: string 14 | mcdata_pr_url: 15 | description: minecraft-data PR number to open a PR here against 16 | required: false 17 | default: '' 18 | type: string 19 | 20 | jobs: 21 | update: 22 | runs-on: ubuntu-latest 23 | 24 | steps: 25 | - name: Checkout repository 26 | uses: actions/checkout@v4 27 | with: 28 | token: ${{ secrets.PAT_PASSWORD }} 29 | 30 | - name: Run updator script 31 | run: cd .github/helper && npm install && node updator.js 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.PAT_PASSWORD }} 34 | MCDATA_BRANCH: ${{ github.event.inputs.mcdata_branch }} 35 | MCDATA_PR_URL: ${{ github.event.inputs.mcdata_pr_url }} 36 | NEW_MC_VERSION: ${{ github.event.inputs.new_mc_version }} 37 | -------------------------------------------------------------------------------- /examples/client_echo/client_echo.js: -------------------------------------------------------------------------------- 1 | const mc = require('minecraft-protocol') 2 | 3 | if (process.argv.length < 4 || process.argv.length > 6) { 4 | console.log('Usage : node echo.js []') 5 | process.exit(1) 6 | } 7 | 8 | const client = mc.createClient({ 9 | host: process.argv[2], 10 | port: parseInt(process.argv[3]), 11 | username: process.argv[4] ? process.argv[4] : 'echo' 12 | }) 13 | client.on('error', function (err) { 14 | console.error(err) 15 | }) 16 | 17 | client.on('connect', function () { 18 | console.info('connected') 19 | }) 20 | client.on('disconnect', function (packet) { 21 | console.log('disconnected: ' + packet.reason) 22 | }) 23 | client.on('end', function () { 24 | console.log('Connection lost') 25 | }) 26 | client.on('chat', function (packet) { 27 | const jsonMsg = JSON.parse(packet.message) 28 | if (jsonMsg.translate === 'chat.type.announcement' || jsonMsg.translate === 'chat.type.text') { 29 | const username = jsonMsg.with[0].text 30 | const msg = jsonMsg.with[1] 31 | if (username === client.username) return 32 | if (msg.text) client.write('chat', { message: msg.text }) 33 | else client.write('chat', { message: msg }) 34 | } 35 | }) 36 | -------------------------------------------------------------------------------- /examples/client_socks_proxy/README.md: -------------------------------------------------------------------------------- 1 | ### Socks5 Proxy example 2 | 3 | - **!This Example only works with socks5 proxys!** 4 | 5 | Make sure your proxy connection is working. 6 | 7 | #### Testing proxy connections with curl on Unix systems or Command prompt on Windows: 8 | ```bash 9 | curl -x "socks5://:" "http://ifconfig.me" 10 | ``` 11 | If you see an ip address the proxy is working. If you see anything else it's not. 12 | 13 | 14 | #### These Errors what do they mean???? 15 | ``` 16 | FetchError: request to https://authserver.mojang.com/authenticate failed, reason: Socket closed 17 | ``` 18 | - The Proxy is not working 19 | 20 | 21 | -------- 22 | ``` 23 | SocksClientError: Socket closed 24 | ``` 25 | - General Socket error Bad Proxy/Proxy refuses the connection 26 | 27 | -------- 28 | ``` 29 | SocksClientError: connect ECONNREFUSED 30 | ``` 31 | - The Connection to the proxy Failed 32 | 33 | -------- 34 | ``` 35 | SocksClientError: Proxy connection timed out 36 | ``` 37 | - Destination Address is wrong/not reachable. Or the Proxy is not working. 38 | 39 | -------- 40 | ``` 41 | Connection Refused: Blocked by CloudFront/CloudFlare 42 | ``` 43 | - Proxy Ip has been banned/block by Cloudflare. 44 | -------------------------------------------------------------------------------- /examples/client_microsoft_auth/client_msal_auth.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const mc = require('minecraft-protocol') 4 | 5 | if (process.argv.length < 4 || process.argv.length > 6) { 6 | console.log('Usage : node echo.js ') 7 | process.exit(1) 8 | } 9 | 10 | const client = mc.createClient({ 11 | host: process.argv[2], 12 | port: parseInt(process.argv[3]), 13 | username: process.argv[4], // your microsoft account email 14 | // password: process.argv[5], // your microsoft account password 15 | auth: 'microsoft' // This option must be present and set to 'microsoft' to use Microsoft Account Authentication. Failure to do so will result in yggdrasil throwing invalid account information. 16 | }) 17 | 18 | client.on('connect', function () { 19 | console.info('connected') 20 | }) 21 | client.on('disconnect', function (packet) { 22 | console.log('disconnected: ' + packet.reason) 23 | }) 24 | client.on('chat', function (packet) { 25 | const jsonMsg = JSON.parse(packet.message) 26 | if (jsonMsg.translate === 'chat.type.announcement' || jsonMsg.translate === 'chat.type.text') { 27 | const username = jsonMsg.with[0].text 28 | const msg = jsonMsg.with[1] 29 | if (username === client.username) return 30 | client.write('chat', { message: msg }) 31 | } 32 | }) 33 | -------------------------------------------------------------------------------- /examples/server_ping/ping.js: -------------------------------------------------------------------------------- 1 | const protocol = require('minecraft-protocol') // Lets define protocol 2 | 3 | if (process.argv.length < 3 || process.argv.length > 3) { // Check for args for prevent crashing etc. 4 | console.log('Usage : node ping.js :[]') 5 | process.exit(1) 6 | } 7 | 8 | function removeColorsFromString (text) { // Removing minecraft colors from strings, because console can`t read it and it will look crazy. 9 | return text.replace(/§./g, '') 10 | } 11 | 12 | let host 13 | let port 14 | 15 | if (!process.argv[2].includes(':')) { // Spliting ip and port if available. 16 | host = process.argv[2] 17 | port = 25565 18 | } else { 19 | [host, port] = process.argv[2].split(':') 20 | port = parseInt(port) 21 | } 22 | 23 | protocol.ping({ host, port }, (err, pingResults) => { // Pinging server and getting result 24 | if (err) throw err 25 | console.log(`${removeColorsFromString(JSON.stringify(pingResults.description.text))}`) // Printing motd to console 26 | // Printing some infos to console 27 | console.log(`${JSON.stringify(pingResults.latency)} ms | ${JSON.stringify(pingResults.players.online)}/${JSON.stringify(pingResults.players.max)} | ${JSON.stringify(removeColorsFromString(pingResults.version.name))}.${JSON.stringify(pingResults.version.protocol)}`) 28 | }) 29 | -------------------------------------------------------------------------------- /examples/client_microsoft_auth/client_microsoft_auth.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const mc = require('minecraft-protocol') 4 | 5 | const [,, host, port, userOrEmail, password] = process.argv 6 | if (!userOrEmail) { 7 | console.log('Usage : node client_microsoft_auth.js []') 8 | process.exit(1) 9 | } 10 | 11 | const client = mc.createClient({ 12 | host, 13 | port: parseInt(port), 14 | username: userOrEmail, // your microsoft account email 15 | password, // your microsoft account password 16 | auth: 'microsoft' // This option must be present and set to 'microsoft' to use Microsoft Account Authentication. Failure to do so will result in yggdrasil throwing invalid account information. 17 | }) 18 | 19 | client.on('connect', function () { 20 | console.info('connected') 21 | }) 22 | client.on('disconnect', function (packet) { 23 | console.log('disconnected: ' + packet.reason) 24 | }) 25 | client.on('chat', function (packet) { 26 | const jsonMsg = JSON.parse(packet.message) 27 | if (jsonMsg.translate === 'chat.type.announcement' || jsonMsg.translate === 'chat.type.text') { 28 | const username = jsonMsg.with[0].text 29 | const msg = jsonMsg.with[1] 30 | if (username === client.username) return 31 | client.write('chat', { message: msg }) 32 | } 33 | }) 34 | -------------------------------------------------------------------------------- /examples/server_custom_channel/server_custom_channel.js: -------------------------------------------------------------------------------- 1 | const mc = require('minecraft-protocol') 2 | 3 | const server = mc.createServer({ 4 | 'online-mode': false, // optional 5 | encryption: false, // optional 6 | version: '1.18.2' 7 | }) 8 | const mcData = require('minecraft-data')(server.version) 9 | const loginPacket = mcData.loginPacket 10 | 11 | server.on('playerJoin', function (client) { 12 | client.write('login', { 13 | ...loginPacket, 14 | enforceSecureChat: false, 15 | entityId: client.id, 16 | isHardcore: false, 17 | gameMode: 0, 18 | previousGameMode: 1, 19 | worldName: 'minecraft:overworld', 20 | hashedSeed: [0, 0], 21 | maxPlayers: server.maxPlayers, 22 | viewDistance: 10, 23 | reducedDebugInfo: false, 24 | enableRespawnScreen: true, 25 | isDebug: false, 26 | isFlat: false 27 | }) 28 | client.registerChannel('node-minecraft-protocol:custom_channel_one', ['string', []], true) 29 | client.registerChannel('node-minecraft-protocol:custom_channel_two', ['string', []], true) 30 | client.write('position', { 31 | x: 0, 32 | y: 1.62, 33 | z: 0, 34 | yaw: 0, 35 | pitch: 0, 36 | flags: 0x00 37 | }) 38 | client.writeChannel('node-minecraft-protocol:custom_channel_two', 'hello from the server') 39 | client.on('node-minecraft-protocol:custom_channel_one', console.log) 40 | }) 41 | -------------------------------------------------------------------------------- /examples/proxy/README.md: -------------------------------------------------------------------------------- 1 | # node-minecraft-protocol proxy 2 | 3 | A proxy, create a nmp server, if you connect to that server with a client, it creates a nmp client which connect to the server you initially provided. 4 | 5 | ## Usage 6 | 7 | 1. start server locally 8 | 2. start client locally 9 | 10 | (you can download them both using `npm install -g minecraft-wrap` and `downloadMinecraft` command) 11 | 12 | 3. Start the proxy 13 | 14 | ``` 15 | usage: node proxy.js [...] 16 | options: 17 | --dump name 18 | print to stdout messages with the specified name. 19 | --dump-all 20 | print to stdout all messages, except those specified with -x. 21 | -x name 22 | do not print messages with this name. 23 | name 24 | a packet name as defined in protocol.json 25 | examples: 26 | node proxy.js --dump-all -x keep_alive -x update_time -x entity_velocity -x rel_entity_move -x entity_look -x entity_move_look -x entity_teleport -x entity_head_rotation -x position localhost 1.8 27 | print all messages except for some of the most prolific. 28 | node examples/proxy.js --dump open_window --dump close_window --dump set_slot --dump window_items --dump craft_progress_bar --dump transaction --dump close_window --dump window_click --dump set_creative_slot --dump enchant_item localhost 1.8 29 | print messages relating to inventory management. 30 | ``` 31 | -------------------------------------------------------------------------------- /src/server/keepalive.js: -------------------------------------------------------------------------------- 1 | module.exports = function (client, server, { 2 | kickTimeout = 30 * 1000, 3 | checkTimeoutInterval = 4 * 1000, 4 | keepAlive: enableKeepAlive = true 5 | }) { 6 | let keepAlive = false 7 | let lastKeepAlive = null 8 | let keepAliveTimer = null 9 | let sendKeepAliveTime 10 | 11 | function keepAliveLoop () { 12 | if (!keepAlive) { return } 13 | 14 | // check if the last keepAlive was too long ago (kickTimeout) 15 | const elapsed = new Date() - lastKeepAlive 16 | if (elapsed > kickTimeout) { 17 | client.end('KeepAliveTimeout') 18 | return 19 | } 20 | sendKeepAliveTime = new Date() 21 | client.write('keep_alive', { 22 | keepAliveId: Math.floor(Math.random() * 2147483648) 23 | }) 24 | } 25 | 26 | function onKeepAlive () { 27 | if (sendKeepAliveTime) client.latency = (new Date()) - sendKeepAliveTime 28 | lastKeepAlive = new Date() 29 | } 30 | 31 | function startKeepAlive () { 32 | keepAlive = true 33 | lastKeepAlive = new Date() 34 | keepAliveTimer = setInterval(keepAliveLoop, checkTimeoutInterval) 35 | client.on('keep_alive', onKeepAlive) 36 | } 37 | 38 | if (enableKeepAlive) { 39 | client.on('state', state => { 40 | if (state === 'play') { 41 | startKeepAlive() 42 | } 43 | }) 44 | } 45 | 46 | client.on('end', () => clearInterval(keepAliveTimer)) 47 | } 48 | -------------------------------------------------------------------------------- /src/transforms/encryption.js: -------------------------------------------------------------------------------- 1 | const Transform = require('readable-stream').Transform 2 | const crypto = require('crypto') 3 | const aesjs = require('aes-js') 4 | 5 | function createCipher (secret) { 6 | if (crypto.getCiphers().includes('aes-128-cfb8')) { 7 | return crypto.createCipheriv('aes-128-cfb8', secret, secret) 8 | } 9 | return new Cipher(secret) 10 | } 11 | 12 | function createDecipher (secret) { 13 | if (crypto.getCiphers().includes('aes-128-cfb8')) { 14 | return crypto.createDecipheriv('aes-128-cfb8', secret, secret) 15 | } 16 | return new Decipher(secret) 17 | } 18 | 19 | class Cipher extends Transform { 20 | constructor (secret) { 21 | super() 22 | this.aes = new aesjs.ModeOfOperation.cfb(secret, secret, 1) // eslint-disable-line new-cap 23 | } 24 | 25 | _transform (chunk, enc, cb) { 26 | try { 27 | const res = this.aes.encrypt(chunk) 28 | cb(null, res) 29 | } catch (e) { 30 | cb(e) 31 | } 32 | } 33 | } 34 | 35 | class Decipher extends Transform { 36 | constructor (secret) { 37 | super() 38 | this.aes = new aesjs.ModeOfOperation.cfb(secret, secret, 1) // eslint-disable-line new-cap 39 | } 40 | 41 | _transform (chunk, enc, cb) { 42 | try { 43 | const res = this.aes.decrypt(chunk) 44 | cb(null, res) 45 | } catch (e) { 46 | cb(e) 47 | } 48 | } 49 | } 50 | 51 | module.exports = { 52 | createCipher, 53 | createDecipher 54 | } 55 | -------------------------------------------------------------------------------- /test/common/clientHelpers.js: -------------------------------------------------------------------------------- 1 | const Registry = require('prismarine-registry') 2 | module.exports = client => { 3 | client.nextMessage = (containing) => { 4 | return new Promise((resolve) => { 5 | function onChat (packet) { 6 | const m = packet.formattedMessage || packet.unsignedChatContent || JSON.stringify({ text: packet.plainMessage }) 7 | if (containing) { 8 | if (m.includes(containing)) return finish(m) 9 | else return 10 | } 11 | return finish(m) 12 | } 13 | client.on('playerChat', onChat) 14 | client.on('systemChat', onChat) // For 1.7.10 15 | 16 | function finish (m) { 17 | client.off('playerChat', onChat) 18 | client.off('systemChat', onChat) 19 | resolve(m) 20 | } 21 | }) 22 | } 23 | 24 | client.on('login', (packet) => { 25 | client.registry ??= Registry(client.version) 26 | if (packet.dimensionCodec) { 27 | client.registry.loadDimensionCodec(packet.dimensionCodec) 28 | } 29 | }) 30 | client.on('registry_data', (data) => { 31 | client.registry ??= Registry(client.version) 32 | client.registry.loadDimensionCodec(data.codec || data) 33 | }) 34 | 35 | client.on('playerJoin', () => { 36 | const ChatMessage = require('prismarine-chat')(client.registry || client.version) 37 | client.parseMessage = (comp) => { 38 | return new ChatMessage(comp) 39 | } 40 | }) 41 | 42 | return client 43 | } 44 | -------------------------------------------------------------------------------- /src/client/versionChecking.js: -------------------------------------------------------------------------------- 1 | const states = require('../states') 2 | 3 | module.exports = function (client, options) { 4 | client.on('disconnect', message => { 5 | if (!message.reason) { return } 6 | // Prevent the disconnect packet handler in the versionChecking code from triggering on PLAY or CONFIGURATION state disconnects 7 | // Since version checking only happens during that HANDSHAKE / LOGIN state. 8 | if (client.state === states.PLAY || client.state === states.CONFIGURATION) { return } 9 | let parsed 10 | try { 11 | parsed = JSON.parse(message.reason) 12 | } catch (error) { 13 | return 14 | } 15 | let text = parsed.text ? parsed.text : parsed 16 | let versionRequired 17 | 18 | if (text.translate && (text.translate.startsWith('multiplayer.disconnect.outdated_') || text.translate.startsWith('multiplayer.disconnect.incompatible'))) { 19 | versionRequired = text.with[0] 20 | } else { 21 | if (text.extra) text = text.extra[0].text 22 | versionRequired = /(?:Outdated client! Please use|Outdated server! I'm still on) (.+)/.exec(text) 23 | versionRequired = versionRequired ? versionRequired[1] : null 24 | } 25 | 26 | if (!versionRequired) { return } 27 | client.emit('error', new Error('This server is version ' + versionRequired + 28 | ', you are using version ' + client.version + ', please specify the correct version in the options.')) 29 | client.end('differentVersionError') 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /examples/client_custom_auth/client_custom_auth.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const mc = require('minecraft-protocol') 4 | 5 | const [, , host, port, username, password] = process.argv 6 | if (!username || !password) { 7 | console.log('Usage : node client_custom_auth.js []') 8 | process.exit(1) 9 | } 10 | 11 | const client = mc.createClient({ 12 | host, 13 | port: parseInt(port), 14 | username, 15 | password, 16 | sessionServer: '', // URL to your session server proxy that changes the expected result of mojang's seession server to mcleaks expected. 17 | // For more information: https://github.com/PrismarineJS/node-yggdrasil/blob/master/src/Server.js#L19 18 | auth: async (client, options) => { 19 | // handle custom authentication your way. 20 | 21 | // client.username = options.username 22 | // options.accessToken = 23 | return options.connect(client) 24 | } 25 | }) 26 | 27 | client.on('connect', function () { 28 | console.info('connected') 29 | }) 30 | client.on('disconnect', function (packet) { 31 | console.log('disconnected: ' + packet.reason) 32 | }) 33 | client.on('chat', function (packet) { 34 | const jsonMsg = JSON.parse(packet.message) 35 | if (jsonMsg.translate === 'chat.type.announcement' || jsonMsg.translate === 'chat.type.text') { 36 | const username = jsonMsg.with[0].text 37 | const msg = jsonMsg.with[1] 38 | if (username === client.username) return 39 | client.write('chat', { message: msg }) 40 | } 41 | }) 42 | -------------------------------------------------------------------------------- /examples/client_electron/renderer/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { ipcRenderer } = require('electron') 4 | 5 | function setContent (content) { 6 | const contentItem = document.getElementById('content') 7 | 8 | contentItem.innerHTML = content 9 | } 10 | 11 | document.getElementById('connect').addEventListener('click', () => { 12 | setContent('connecting...') 13 | const authType = document.getElementById('type') 14 | 15 | const data = { 16 | host: document.getElementById('host').value, 17 | port: parseInt(document.getElementById('port').value), 18 | username: document.getElementById('username').value, 19 | password: document.getElementById('password').value === '' ? undefined : document.getElementById('password').value 20 | } 21 | if (authType.value === 'Microsoft') { 22 | data.auth = 'microsoft' 23 | delete data.password 24 | } 25 | ipcRenderer.send('connect', data) 26 | }) 27 | 28 | function chat () { 29 | ipcRenderer.send('chat', document.getElementById('chat').value) 30 | document.getElementById('chat').value = '' 31 | } 32 | document.getElementById('chat').addEventListener('keyup', function onEvent (e) { 33 | if (e.keyCode === 13) { 34 | chat() 35 | } 36 | }) 37 | 38 | document.getElementById('send').addEventListener('click', () => { 39 | chat() 40 | }) 41 | 42 | window.onAuthTypeChange = function () { 43 | const authType = document.getElementById('type') 44 | console.log('set auth type to', authType) 45 | } 46 | 47 | ipcRenderer.on('content', (event, content) => { 48 | setContent(content) 49 | }) 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2020, PrismarineJS 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /examples/client_electron/main.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const path = require('path') 4 | const { app, ipcMain, dialog } = require('electron') 5 | const mc = require('minecraft-protocol') 6 | 7 | const Window = require('./Window') 8 | 9 | require('electron-reload')(__dirname) 10 | 11 | function main () { 12 | const mainWindow = new Window({ 13 | file: path.join('renderer', 'index.html') 14 | }) 15 | 16 | mainWindow.once('show', () => { 17 | }) 18 | 19 | ipcMain.on('connect', (e, data) => { 20 | data.onMsaCode = (data) => { 21 | dialog.showMessageBoxSync({ 22 | type: 'info', 23 | message: 'Please authenticate now:\n' + data.message 24 | }) 25 | } 26 | const client = mc.createClient(data) 27 | client.on('login', () => mainWindow.send('content', 'connected')) 28 | client.on('error', (err) => { 29 | dialog.showMessageBoxSync({ 30 | type: 'error', 31 | message: err.stack 32 | }) 33 | }) 34 | 35 | let chat = '' 36 | 37 | client.on('chat', function (packet) { 38 | const jsonMsg = JSON.parse(packet.message) 39 | if (jsonMsg.translate === 'chat.type.announcement' || jsonMsg.translate === 'chat.type.text') { 40 | const username = jsonMsg.with[0].text 41 | const msg = jsonMsg.with[1] 42 | chat += `${username} > ${msg}
` 43 | mainWindow.send('content', chat) 44 | } 45 | }) 46 | ipcMain.on('chat', (e, chat2) => { 47 | client.write('chat', { message: chat2 }) 48 | }) 49 | }) 50 | } 51 | 52 | app.on('ready', main) 53 | 54 | app.on('window-all-closed', function () { 55 | app.quit() 56 | }) 57 | -------------------------------------------------------------------------------- /examples/ipc/ipc_server.js: -------------------------------------------------------------------------------- 1 | /** IPC Connection example 2 | * 3 | * This example shows how to use a IPC connection to communicate with a server or client. 4 | * 5 | * See the node.js documentation about IPC connections here: https://nodejs.org/api/net.html#identifying-paths-for-ipc-connections 6 | */ 7 | 8 | const nmp = require('minecraft-protocol') 9 | const net = require('net') 10 | 11 | const ipcName = 'minecraft-ipc' 12 | 13 | // IPC with node.js works differently on windows and unix systems 14 | let ipcPath 15 | if (process.platform === 'win32') { 16 | ipcPath = `\\\\.\\pipe\\${ipcName}` 17 | } else { 18 | ipcPath = `/tmp/${ipcName}.sock` 19 | } 20 | 21 | const server = nmp.createServer({ 22 | version: '1.18.2', 23 | socketType: 'ipc', 24 | host: ipcPath, // When the optional option socketType is 'ipc' the host becomes the socket path 25 | 'online-mode': false 26 | }) 27 | 28 | server.on('listening', () => { 29 | console.info('Server listening on', server.socketServer.address()) 30 | connectAClient() 31 | }) 32 | 33 | server.on('login', (client) => { 34 | console.info(`New user '${client.username}' logged into the server`) 35 | }) 36 | 37 | function connectAClient () { 38 | const client = nmp.createClient({ 39 | version: '1.18.2', 40 | username: 'ipc_client', 41 | connect: (client) => { 42 | const socket = net.connect(ipcPath, () => { 43 | client.setSocket(socket) 44 | client.emit('connect') 45 | }) 46 | }, 47 | auth: 'offline' 48 | }) 49 | client.on('connect', () => console.info('Client connected to server')) 50 | client.on('end', () => console.info('Client disconnected from server')) 51 | } 52 | -------------------------------------------------------------------------------- /src/server/handshake.js: -------------------------------------------------------------------------------- 1 | const states = require('../states') 2 | 3 | module.exports = function (client, server, { version, fallbackVersion }) { 4 | client.once('set_protocol', onHandshake) 5 | 6 | function onHandshake (packet) { 7 | client.serverHost = packet.serverHost 8 | client.serverPort = packet.serverPort 9 | client.protocolVersion = packet.protocolVersion 10 | 11 | if (version === false) { 12 | const mcData = require('minecraft-data')(client.protocolVersion) 13 | if (mcData) { 14 | client.version = client.protocolVersion 15 | client._supportFeature = mcData.supportFeature 16 | client._hasBundlePacket = mcData.supportFeature('hasBundlePacket') 17 | } else { 18 | let fallback 19 | if (fallbackVersion !== undefined) { 20 | fallback = require('minecraft-data')(fallbackVersion) 21 | } 22 | if (fallback) { 23 | client.version = fallback.version.version 24 | client._supportFeature = fallback.supportFeature 25 | client._hasBundlePacket = fallback.supportFeature('hasBundlePacket') 26 | } else { 27 | client.end('Protocol version ' + client.protocolVersion + ' is not supported') 28 | } 29 | } 30 | } else if (client.protocolVersion !== server.mcversion.version && packet.nextState !== 1) { 31 | client.end('Wrong protocol version, expected: ' + server.mcversion.version + ' and you are using: ' + client.protocolVersion) 32 | } 33 | 34 | if (packet.nextState === 1) { 35 | client.state = states.STATUS 36 | } else if (packet.nextState === 2) { 37 | client.state = states.LOGIN 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/client/setProtocol.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const states = require('../states') 4 | 5 | module.exports = function (client, options) { 6 | client.on('connect', onConnect) 7 | 8 | function onConnect () { 9 | if (client.wait_connect) { 10 | client.on('connect_allowed', next) 11 | } else { 12 | next() 13 | } 14 | 15 | function next () { 16 | const mcData = require('minecraft-data')(client.version) 17 | let taggedHost = options.host 18 | if (client.tagHost) taggedHost += client.tagHost 19 | if (options.fakeHost) taggedHost = options.fakeHost 20 | 21 | client.write('set_protocol', { 22 | protocolVersion: options.protocolVersion, 23 | serverHost: taggedHost, 24 | serverPort: options.port, 25 | nextState: 2 26 | }) 27 | client.state = states.LOGIN 28 | client.write('login_start', { 29 | username: client.username, 30 | signature: (client.profileKeys && !mcData.supportFeature('useChatSessions')) 31 | ? { 32 | timestamp: BigInt(client.profileKeys.expiresOn.getTime()), // should probably be called "expireTime" 33 | // Remove padding on the public key: not needed in vanilla server but matches how vanilla client looks 34 | publicKey: client.profileKeys.public.export({ type: 'spki', format: 'der' }), 35 | signature: mcData.supportFeature('profileKeySignatureV2') 36 | ? client.profileKeys.signatureV2 37 | : client.profileKeys.signature 38 | } 39 | : null, 40 | playerUUID: client.session?.selectedProfile?.id ?? client.uuid 41 | }) 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /examples/client_http_proxy/client_http_proxy.js: -------------------------------------------------------------------------------- 1 | const mc = require('minecraft-protocol') 2 | const Http = require('http') 3 | const ProxyAgent = require('proxy-agent') 4 | 5 | if (process.argv.length < 6 || process.argv.length > 8) { 6 | console.log('Usage : node client_http_proxy.js [] []') 7 | process.exit(1) 8 | } 9 | 10 | const proxyHost = process.argv[4] 11 | const proxyPort = process.argv[5] 12 | 13 | const client = mc.createClient({ 14 | connect: (client) => { 15 | const req = Http.request({ 16 | host: proxyHost, 17 | port: proxyPort, 18 | method: 'CONNECT', 19 | path: process.argv[2] + ':' + parseInt(process.argv[3]) 20 | }) 21 | req.end() 22 | 23 | req.on('connect', (res, stream) => { 24 | client.setSocket(stream) 25 | client.emit('connect') 26 | }) 27 | }, 28 | agent: new ProxyAgent({ protocol: 'http', host: proxyHost, port: proxyPort }), 29 | username: process.argv[6] ? process.argv[6] : 'echo', 30 | password: process.argv[7] 31 | }) 32 | 33 | client.on('connect', function () { 34 | console.info('connected') 35 | }) 36 | client.on('disconnect', function (packet) { 37 | console.log('disconnected: ' + packet.reason) 38 | }) 39 | client.on('end', function () { 40 | console.log('Connection lost') 41 | }) 42 | client.on('chat', function (packet) { 43 | const jsonMsg = JSON.parse(packet.message) 44 | if (jsonMsg.translate === 'chat.type.announcement' || jsonMsg.translate === 'chat.type.text') { 45 | const username = jsonMsg.with[0].text 46 | const msg = jsonMsg.with[1] 47 | if (username === client.username) return 48 | client.write('chat', { message: msg }) 49 | } 50 | }) 51 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | Lint: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Use Node.js 22.x 18 | uses: actions/setup-node@v1.4.4 19 | with: 20 | node-version: 22.x 21 | - run: npm i && npm run lint 22 | PrepareSupportedVersions: 23 | runs-on: ubuntu-latest 24 | outputs: 25 | matrix: ${{ steps.set-matrix.outputs.matrix }} 26 | 27 | steps: 28 | - uses: actions/checkout@v2 29 | - name: Use Node.js 22.x 30 | uses: actions/setup-node@v1.4.4 31 | with: 32 | node-version: 22.x 33 | - id: set-matrix 34 | run: | 35 | node -e " 36 | const supportedVersions = require('./src/version').supportedVersions; 37 | console.log('matrix='+JSON.stringify({'include': supportedVersions.map(mcVersion => ({mcVersion}))})) 38 | " >> $GITHUB_OUTPUT 39 | test: 40 | needs: PrepareSupportedVersions 41 | runs-on: ubuntu-latest 42 | strategy: 43 | matrix: ${{fromJson(needs.PrepareSupportedVersions.outputs.matrix)}} 44 | fail-fast: false 45 | 46 | steps: 47 | - uses: actions/checkout@v2 48 | - name: Use Node.js 22.x 49 | uses: actions/setup-node@v1 50 | with: 51 | node-version: 22.x 52 | - name: Setup Java JDK 53 | uses: actions/setup-java@v1.4.3 54 | with: 55 | java-version: '21' 56 | distribution: 'adopt' 57 | - name: Install dependencies 58 | run: npm install 59 | - name: Run tests 60 | run: npm run mochaTest -- -g ${{ matrix.mcVersion }}v 61 | -------------------------------------------------------------------------------- /src/client/tcp_dns.js: -------------------------------------------------------------------------------- 1 | const net = require('net') 2 | const dns = require('dns') 3 | 4 | module.exports = function (client, options) { 5 | // Default options 6 | options.port = options.port || 25565 7 | options.host = options.host || 'localhost' 8 | 9 | if (!options.connect) { 10 | options.connect = (client) => { 11 | // Use stream if provided 12 | if (options.stream) { 13 | client.setSocket(options.stream) 14 | client.emit('connect') 15 | return 16 | } 17 | 18 | // If port was not defined (defauls to 25565), host is not an ip neither localhost 19 | if (options.port === 25565 && net.isIP(options.host) === 0 && options.host !== 'localhost') { 20 | // Try to resolve SRV records for the comain 21 | dns.resolveSrv('_minecraft._tcp.' + options.host, (err, addresses) => { 22 | // Error resolving domain 23 | if (err) { 24 | // Could not resolve SRV lookup, connect directly 25 | client.setSocket(net.connect(options.port, options.host)) 26 | return 27 | } 28 | 29 | // SRV Lookup resolved conrrectly 30 | if (addresses && addresses.length > 0) { 31 | options.host = addresses[0].name 32 | options.port = addresses[0].port 33 | client.setSocket(net.connect(addresses[0].port, addresses[0].name)) 34 | } else { 35 | // Otherwise, just connect using the provided hostname and port 36 | client.setSocket(net.connect(options.port, options.host)) 37 | } 38 | }) 39 | } else { 40 | // Otherwise, just connect using the provided hostname and port 41 | client.setSocket(net.connect(options.port, options.host)) 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /docs/FAQ.md: -------------------------------------------------------------------------------- 1 | # FAQ 2 | 3 | This Frequently Asked Question document is meant to help people for the most common things. 4 | 5 | ## How to hide errors ? 6 | 7 | Use `hideErrors: true` in createClient options 8 | You may also choose to add these listeners : 9 | 10 | ```js 11 | client.on('error', () => {}) 12 | client.on('end', () => {}) 13 | ``` 14 | 15 | ## How can I make a proxy with this ? 16 | 17 | * Check out our WIP proxy lib 18 | * See this example 19 | * Read this issue 20 | * check out 21 | * Check out this app 22 | 23 | ## Can you support alternative auth methods? 24 | 25 | Supporting alternative authentcation methods has been a long standing issue with Prismarine for awhile. We do add support for using your own custom authentication method by providing a function to the `options.auth` property. In order to keep the legitimacy of the project, and to prevent bad attention from Mojang, we will not be supporting any custom authentication methods in the official repositories. 26 | 27 | It is up to the end user to support and maintain the authentication protocol if this is used as support in many of the official channels will be limited. 28 | 29 | If you still wish to proceed, please make sure to throughly read and attempt to understand all implementations of the authentcation you wish to implement. Using an non-official authentication server can make you vulnerable to all different kinds of attacks which are not limited to insecure and/or malicious code! We will not be held responsible for anything you mess up. 30 | -------------------------------------------------------------------------------- /examples/server_world/mc.js: -------------------------------------------------------------------------------- 1 | const mc = require('minecraft-protocol') 2 | const Chunk = require('prismarine-chunk')('1.16.3') 3 | const Vec3 = require('vec3') 4 | const server = mc.createServer({ 5 | 'online-mode': true, 6 | encryption: true, 7 | host: '0.0.0.0', 8 | port: 25565, 9 | version: '1.16' 10 | }) 11 | const mcData = require('minecraft-data')(server.version) 12 | const loginPacket = mcData.loginPacket 13 | const chunk = new Chunk() 14 | 15 | for (let x = 0; x < 16; x++) { 16 | for (let z = 0; z < 16; z++) { 17 | chunk.setBlockType(new Vec3(x, 100, z), mcData.blocksByName.grass_block.id) 18 | chunk.setBlockData(new Vec3(x, 100, z), 1) 19 | for (let y = 0; y < 256; y++) { 20 | chunk.setSkyLight(new Vec3(x, y, z), 15) 21 | } 22 | } 23 | } 24 | 25 | server.on('playerJoin', function (client) { 26 | client.write('login', { 27 | ...loginPacket, 28 | entityId: client.id, 29 | isHardcore: false, 30 | gameMode: 0, 31 | previousGameMode: 1, 32 | worldName: 'minecraft:overworld', 33 | hashedSeed: [0, 0], 34 | maxPlayers: server.maxPlayers, 35 | viewDistance: 10, 36 | reducedDebugInfo: false, 37 | enableRespawnScreen: true, 38 | isDebug: false, 39 | isFlat: false 40 | }) 41 | client.write('map_chunk', { 42 | x: 0, 43 | z: 0, 44 | groundUp: true, 45 | biomes: chunk.dumpBiomes !== undefined ? chunk.dumpBiomes() : undefined, 46 | heightmaps: { 47 | type: 'compound', 48 | name: '', 49 | value: {} // Client will accept fake heightmap 50 | }, 51 | bitMap: chunk.getMask(), 52 | chunkData: chunk.dump(), 53 | blockEntities: [] 54 | }) 55 | client.write('position', { 56 | x: 15, 57 | y: 101, 58 | z: 15, 59 | yaw: 137, 60 | pitch: 0, 61 | flags: 0x00 62 | }) 63 | }) 64 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "minecraft-protocol", 3 | "version": "1.62.0", 4 | "description": "Parse and serialize minecraft packets, plus authentication and encryption.", 5 | "main": "src/index.js", 6 | "types": "src/index.d.ts", 7 | "repository": { 8 | "type": "git", 9 | "url": "git://github.com/PrismarineJS/node-minecraft-protocol.git" 10 | }, 11 | "scripts": { 12 | "test": "npm run mochaTest", 13 | "mochaTest": "mocha --recursive --reporter spec --exit", 14 | "lint": "standard", 15 | "fix": "standard --fix", 16 | "pretest": "npm run lint", 17 | "prepublishOnly": "cp docs/README.md README.md" 18 | }, 19 | "keywords": [ 20 | "minecraft", 21 | "protocol", 22 | "parse", 23 | "serialize", 24 | "packet", 25 | "authentication", 26 | "encrypton", 27 | "bot" 28 | ], 29 | "author": "Andrew Kelley", 30 | "license": "BSD-3-Clause", 31 | "engines": { 32 | "node": ">=22" 33 | }, 34 | "browser": "src/browser.js", 35 | "devDependencies": { 36 | "@types/node": "^24.0.4", 37 | "espower-loader": "^1.0.0", 38 | "intelli-espower-loader": "^1.0.0", 39 | "minecraft-packets": "^1.1.5", 40 | "minecraft-protocol": "file:.", 41 | "minecraft-wrap": "^1.2.3", 42 | "mocha": "^11.0.1", 43 | "power-assert": "^1.0.0", 44 | "standard": "^17.0.0", 45 | "prismarine-registry": "^1.8.0" 46 | }, 47 | "dependencies": { 48 | "@types/node-rsa": "^1.1.4", 49 | "@types/readable-stream": "^4.0.0", 50 | "aes-js": "^3.1.2", 51 | "buffer-equal": "^1.0.0", 52 | "debug": "^4.3.2", 53 | "endian-toggle": "^0.0.0", 54 | "lodash.merge": "^4.3.0", 55 | "minecraft-data": "^3.78.0", 56 | "minecraft-folder-path": "^1.2.0", 57 | "node-fetch": "^2.6.1", 58 | "node-rsa": "^0.4.2", 59 | "prismarine-auth": "^2.2.0", 60 | "prismarine-chat": "^1.10.0", 61 | "prismarine-nbt": "^2.5.0", 62 | "prismarine-realms": "^1.2.0", 63 | "protodef": "^1.17.0", 64 | "readable-stream": "^4.1.0", 65 | "uuid-1345": "^1.0.1", 66 | "yggdrasil": "^1.4.0" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /test/benchmark.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | 3 | const ITERATIONS = 10000 4 | 5 | const mc = require('../') 6 | const states = mc.states 7 | 8 | for (const supportedVersion of mc.supportedVersions) { 9 | const mcData = require('minecraft-data')(supportedVersion) 10 | const version = mcData.version 11 | const positionFlags = mcData.isNewerOrEqualTo('1.21.3') ? { flags: { onGround: true, hasHorizontalCollision: false } } : { onGround: true } 12 | const testDataWrite = [ 13 | { name: 'keep_alive', params: { keepAliveId: 957759560 } }, 14 | // TODO: 1.19+ `chat` -> `player_chat` feature toggle 15 | // { name: 'chat', params: { message: ' Hello World!' } }, 16 | { name: 'position_look', params: { x: 6.5, y: 65.62, stance: 67.24, z: 7.5, yaw: 0, pitch: 0, ...positionFlags } } 17 | // TODO: add more packets for better quality data 18 | ] 19 | 20 | describe('benchmark ' + supportedVersion + 'v', function () { 21 | this.timeout(60 * 1000) 22 | const inputData = [] 23 | it('bench serializing', function (done) { 24 | const serializer = mc.createSerializer({ state: states.PLAY, isServer: false, version: version.minecraftVersion }) 25 | let i, j 26 | console.log('Beginning write test') 27 | const start = Date.now() 28 | for (i = 0; i < ITERATIONS; i++) { 29 | for (j = 0; j < testDataWrite.length; j++) { 30 | inputData.push(serializer.createPacketBuffer(testDataWrite[j])) 31 | } 32 | } 33 | const result = (Date.now() - start) / 1000 34 | console.log('Finished write test in ' + result + ' seconds') 35 | done() 36 | }) 37 | 38 | it('bench parsing', function (done) { 39 | const deserializer = mc.createDeserializer({ state: states.PLAY, isServer: true, version: version.minecraftVersion }) 40 | console.log('Beginning read test') 41 | const start = Date.now() 42 | for (let j = 0; j < inputData.length; j++) { 43 | deserializer.parsePacketBuffer(inputData[j]) 44 | } 45 | console.log('Finished read test in ' + (Date.now() - start) / 1000 + ' seconds') 46 | done() 47 | }) 48 | }) 49 | } 50 | -------------------------------------------------------------------------------- /examples/client_custom_packets/client_custom_packets.js: -------------------------------------------------------------------------------- 1 | const mc = require('minecraft-protocol') 2 | 3 | if (process.argv.length < 4 || process.argv.length > 6) { 4 | console.log('Usage : node echo.js [] []') 5 | process.exit(1) 6 | } 7 | 8 | const customPackets = { 9 | 1.8: { 10 | play: { 11 | toClient: { 12 | types: { 13 | packet_custom_name: [ 14 | 'container', [ 15 | { 16 | name: 'age', 17 | type: 'i64' 18 | }, 19 | { 20 | name: 'time', 21 | type: 'i64' 22 | } 23 | ] 24 | ], 25 | packet: [ 26 | 'container', 27 | [ 28 | { 29 | name: 'name', 30 | type: [ 31 | 'mapper', 32 | { 33 | type: 'varint', 34 | mappings: { 35 | '0x7A': 'custom_name' 36 | } 37 | } 38 | ] 39 | }, 40 | { 41 | name: 'params', 42 | type: [ 43 | 'switch', 44 | { 45 | compareTo: 'name', 46 | fields: { 47 | custom_name: 'packet_custom_name' 48 | } 49 | } 50 | ] 51 | } 52 | ] 53 | ] 54 | } 55 | } 56 | } 57 | } 58 | } 59 | 60 | const client = mc.createClient({ 61 | host: process.argv[2], 62 | port: parseInt(process.argv[3]), 63 | username: process.argv[4] ? process.argv[4] : 'echo', 64 | password: process.argv[5], 65 | customPackets 66 | }) 67 | 68 | client.on('connect', function () { 69 | console.info('connected') 70 | }) 71 | client.on('disconnect', function (packet) { 72 | console.log('disconnected: ' + packet.reason) 73 | }) 74 | client.on('end', function () { 75 | console.log('Connection lost') 76 | }) 77 | 78 | client.on('login', function () { 79 | client.deserializer.write(Buffer.from('7A0000000000909327fffffffffffffc18', 'hex')) 80 | console.log('login') 81 | }) 82 | 83 | client.on('custom_name', function (packet) { 84 | console.log(packet) 85 | }) 86 | -------------------------------------------------------------------------------- /src/transforms/framing.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const [readVarInt, writeVarInt, sizeOfVarInt] = require('protodef').types.varint 4 | const Transform = require('readable-stream').Transform 5 | 6 | module.exports.createSplitter = function () { 7 | return new Splitter() 8 | } 9 | 10 | module.exports.createFramer = function () { 11 | return new Framer() 12 | } 13 | 14 | class Framer extends Transform { 15 | _transform (chunk, enc, cb) { 16 | const varIntSize = sizeOfVarInt(chunk.length) 17 | const buffer = Buffer.alloc(varIntSize + chunk.length) 18 | writeVarInt(chunk.length, buffer, 0) 19 | chunk.copy(buffer, varIntSize) 20 | this.push(buffer) 21 | return cb() 22 | } 23 | } 24 | 25 | const LEGACY_PING_PACKET_ID = 0xfe 26 | 27 | class Splitter extends Transform { 28 | constructor () { 29 | super() 30 | this.buffer = Buffer.alloc(0) 31 | this.recognizeLegacyPing = false 32 | } 33 | 34 | _transform (chunk, enc, cb) { 35 | this.buffer = Buffer.concat([this.buffer, chunk]) 36 | 37 | if (this.recognizeLegacyPing && this.buffer[0] === LEGACY_PING_PACKET_ID) { 38 | // legacy_server_list_ping packet follows a different protocol format 39 | // prefix the encoded varint packet id for the deserializer 40 | const header = Buffer.alloc(sizeOfVarInt(LEGACY_PING_PACKET_ID)) 41 | writeVarInt(LEGACY_PING_PACKET_ID, header, 0) 42 | let payload = this.buffer.slice(1) // remove 0xfe packet id 43 | if (payload.length === 0) payload = Buffer.from('\0') // TODO: update minecraft-data to recognize a lone 0xfe, https://github.com/PrismarineJS/minecraft-data/issues/95 44 | this.push(Buffer.concat([header, payload])) 45 | return cb() 46 | } 47 | 48 | let offset = 0 49 | let value, size 50 | let stop = false 51 | try { 52 | ({ value, size } = readVarInt(this.buffer, offset)) 53 | } catch (e) { 54 | if (!(e.partialReadError)) { 55 | throw e 56 | } else { stop = true } 57 | } 58 | if (!stop) { 59 | while (this.buffer.length >= offset + size + value) { 60 | try { 61 | this.push(this.buffer.slice(offset + size, offset + size + value)) 62 | offset += size + value; 63 | ({ value, size } = readVarInt(this.buffer, offset)) 64 | } catch (e) { 65 | if (e.partialReadError) { 66 | break 67 | } else { throw e } 68 | } 69 | } 70 | } 71 | this.buffer = this.buffer.slice(offset) 72 | return cb() 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /examples/client_chat/client_chat.js: -------------------------------------------------------------------------------- 1 | const mc = require('minecraft-protocol') 2 | const readline = require('readline') 3 | const rl = readline.createInterface({ 4 | input: process.stdin, 5 | output: process.stdout, 6 | terminal: false, 7 | prompt: 'Enter a message> ' 8 | }) 9 | 10 | const [,, host, port, username] = process.argv 11 | if (!host || !port) { 12 | console.error('Usage: node client_chat.js ') 13 | console.error('Usage (offline mode): node client_chat.js offline') 14 | process.exit(1) 15 | } 16 | 17 | const client = mc.createClient({ 18 | host, 19 | port, 20 | username, 21 | auth: username === 'offline' ? 'offline' : 'microsoft' 22 | }) 23 | 24 | // Boilerplate 25 | client.on('disconnect', function (packet) { 26 | console.log('Disconnected from server : ' + packet.reason) 27 | }) 28 | 29 | client.on('end', function () { 30 | console.log('Connection lost') 31 | process.exit() 32 | }) 33 | 34 | client.on('error', function (err) { 35 | console.log('Error occurred') 36 | console.log(err) 37 | process.exit(1) 38 | }) 39 | 40 | client.on('connect', () => { 41 | const ChatMessage = require('prismarine-chat')(client.version) 42 | 43 | console.log('Connected to server') 44 | rl.prompt() 45 | 46 | client.on('playerChat', function ({ senderName, plainMessage, unsignedContent, formattedMessage, verified }) { 47 | let content 48 | 49 | const allowInsecureChat = true 50 | 51 | if (formattedMessage) content = JSON.parse(formattedMessage) 52 | else if (allowInsecureChat && unsignedContent) content = JSON.parse(unsignedContent) 53 | else content = { text: plainMessage } 54 | 55 | const chat = new ChatMessage(content) 56 | console.log(senderName, { trugie: 'Verified:', false: 'UNVERIFIED:' }[verified] || '', chat.toAnsi()) 57 | }) 58 | }) 59 | 60 | // Send the queued messages 61 | const queuedChatMessages = [] 62 | client.on('state', function (newState) { 63 | if (newState === mc.states.PLAY) { 64 | queuedChatMessages.forEach(message => client.chat(message)) 65 | queuedChatMessages.length = 0 66 | } 67 | }) 68 | 69 | // Listen for messages written to the console, send them to game chat 70 | rl.on('line', function (line) { 71 | if (line === '') { 72 | return 73 | } else if (line === '/quit') { 74 | console.info('Disconnected from ' + host + ':' + port) 75 | client.end() 76 | return 77 | } else if (line === '/end') { 78 | console.info('Forcibly ended client') 79 | process.exit(0) 80 | } 81 | if (!client.chat) { 82 | queuedChatMessages.push(line) 83 | } else { 84 | client.chat(line) 85 | } 86 | }) 87 | -------------------------------------------------------------------------------- /src/transforms/compression.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const [readVarInt, writeVarInt, sizeOfVarInt] = require('protodef').types.varint 4 | const zlib = require('zlib') 5 | const Transform = require('readable-stream').Transform 6 | 7 | module.exports.createCompressor = function (threshold) { 8 | return new Compressor(threshold) 9 | } 10 | 11 | module.exports.createDecompressor = function (threshold, hideErrors) { 12 | return new Decompressor(threshold, hideErrors) 13 | } 14 | 15 | class Compressor extends Transform { 16 | constructor (compressionThreshold = -1) { 17 | super() 18 | this.compressionThreshold = compressionThreshold 19 | } 20 | 21 | _transform (chunk, enc, cb) { 22 | if (chunk.length >= this.compressionThreshold) { 23 | zlib.deflate(chunk, (err, newChunk) => { 24 | if (err) { return cb(err) } 25 | const buf = Buffer.alloc(sizeOfVarInt(chunk.length) + newChunk.length) 26 | const offset = writeVarInt(chunk.length, buf, 0) 27 | newChunk.copy(buf, offset) 28 | this.push(buf) 29 | return cb() 30 | }) 31 | } else { 32 | const buf = Buffer.alloc(sizeOfVarInt(0) + chunk.length) 33 | const offset = writeVarInt(0, buf, 0) 34 | chunk.copy(buf, offset) 35 | this.push(buf) 36 | return cb() 37 | } 38 | } 39 | } 40 | 41 | class Decompressor extends Transform { 42 | constructor (compressionThreshold = -1, hideErrors = false) { 43 | super() 44 | this.compressionThreshold = compressionThreshold 45 | this.hideErrors = hideErrors 46 | } 47 | 48 | _transform (chunk, enc, cb) { 49 | const { size, value, error } = readVarInt(chunk, 0) 50 | if (error) { return cb(error) } 51 | if (value === 0) { 52 | this.push(chunk.slice(size)) 53 | return cb() 54 | } else { 55 | zlib.unzip(chunk.slice(size), { finishFlush: 2 /* Z_SYNC_FLUSH = 2, but when using Browserify/Webpack it doesn't exist */ }, (err, newBuf) => { /** Fix by lefela4. */ 56 | if (err) { 57 | if (!this.hideErrors) { 58 | console.error('problem inflating chunk') 59 | console.error('uncompressed length ' + value) 60 | console.error('compressed length ' + chunk.length) 61 | console.error('hex ' + chunk.toString('hex')) 62 | console.log(err) 63 | } 64 | return cb() 65 | } 66 | if (newBuf.length !== value && !this.hideErrors) { 67 | console.error('uncompressed length should be ' + value + ' but is ' + newBuf.length) 68 | } 69 | this.push(newBuf) 70 | return cb() 71 | }) 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/createServer.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const DefaultServerImpl = require('./server') 4 | const NodeRSA = require('node-rsa') 5 | const plugins = [ 6 | require('./server/handshake'), 7 | require('./server/keepalive'), 8 | require('./server/login'), 9 | require('./server/ping') 10 | ] 11 | 12 | module.exports = createServer 13 | 14 | function createServer (options = {}) { 15 | const { 16 | host = undefined, // undefined means listen to all available ipv4 and ipv6 adresses 17 | // (see https://nodejs.org/api/net.html#net_server_listen_port_host_backlog_callback for details) 18 | 'server-port': serverPort, 19 | port = serverPort || 25565, 20 | motd = 'A Minecraft server', 21 | 'max-players': maxPlayersOld = 20, 22 | maxPlayers: maxPlayersNew = 20, 23 | Server = DefaultServerImpl, 24 | version, 25 | favicon, 26 | customPackets, 27 | motdMsg, // This is when you want to send formated motd's from ChatMessage instances 28 | socketType = 'tcp' 29 | } = options 30 | 31 | const maxPlayers = options['max-players'] !== undefined ? maxPlayersOld : maxPlayersNew 32 | 33 | const optVersion = version === undefined || version === false ? require('./version').defaultVersion : version 34 | 35 | const mcData = require('minecraft-data')(optVersion) 36 | if (!mcData) throw new Error(`unsupported protocol version: ${optVersion}`) 37 | const mcversion = mcData.version 38 | const hideErrors = options.hideErrors || false 39 | 40 | const server = new Server(mcversion.minecraftVersion, customPackets, hideErrors) 41 | server.mcversion = mcversion 42 | server.motd = motd 43 | server.motdMsg = motdMsg 44 | server.maxPlayers = maxPlayers 45 | server.playerCount = 0 46 | server.onlineModeExceptions = Object.create(null) 47 | server.favicon = favicon 48 | server.options = options 49 | options.registryCodec = options.registryCodec || mcData.registryCodec || mcData.loginPacket?.dimensionCodec 50 | 51 | // The RSA keypair can take some time to generate 52 | // and is only needed for online-mode 53 | // So we generate it lazily when needed 54 | Object.defineProperty(server, 'serverKey', { 55 | configurable: true, 56 | get () { 57 | this.serverKey = new NodeRSA({ b: 1024 }) 58 | return this.serverKey 59 | }, 60 | set (value) { 61 | delete this.serverKey 62 | this.serverKey = value 63 | } 64 | }) 65 | 66 | server.on('connection', function (client) { 67 | plugins.forEach(plugin => plugin(client, server, options)) 68 | }) 69 | if (socketType === 'ipc') { 70 | server.listen(host) 71 | } else { 72 | server.listen(port, host) 73 | } 74 | return server 75 | } 76 | -------------------------------------------------------------------------------- /src/transforms/serializer.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { ProtoDef, Serializer, FullPacketParser } = require('protodef') 4 | const { ProtoDefCompiler } = require('protodef').Compiler 5 | 6 | const nbt = require('prismarine-nbt') 7 | const minecraft = require('../datatypes/minecraft') 8 | const states = require('../states') 9 | const merge = require('lodash.merge') 10 | 11 | const minecraftData = require('minecraft-data') 12 | const protocols = {} 13 | 14 | function createProtocol (state, direction, version, customPackets, compiled = true) { 15 | const key = `${state};${direction};${version}${compiled ? ';c' : ''}` 16 | if (protocols[key]) { return protocols[key] } 17 | 18 | const mcData = minecraftData(version) 19 | const versionInfo = minecraftData.versionsByMinecraftVersion.pc[version] 20 | if (mcData === null) { 21 | throw new Error(`No data available for version ${version}`) 22 | } else if (versionInfo && versionInfo.version !== mcData.version.version) { 23 | // The protocol version returned by node-minecraft-data constructor does not match the data in minecraft-data's protocolVersions.json 24 | throw new Error(`Unsupported protocol version '${versionInfo.version}' (attempted to use '${mcData.version.version}' data); try updating your packages with 'npm update'`) 25 | } 26 | 27 | const mergedProtocol = merge(mcData.protocol, customPackets?.[mcData.version.majorVersion] ?? {}) 28 | 29 | if (compiled) { 30 | const compiler = new ProtoDefCompiler() 31 | compiler.addTypes(require('../datatypes/compiler-minecraft')) 32 | compiler.addProtocol(mergedProtocol, [state, direction]) 33 | nbt.addTypesToCompiler('big', compiler) 34 | const proto = compiler.compileProtoDefSync() 35 | protocols[key] = proto 36 | return proto 37 | } 38 | 39 | const proto = new ProtoDef(false) 40 | proto.addTypes(minecraft) 41 | proto.addProtocol(mergedProtocol, [state, direction]) 42 | nbt.addTypesToInterperter('big', proto) 43 | protocols[key] = proto 44 | return proto 45 | } 46 | 47 | function createSerializer ({ state = states.HANDSHAKING, isServer = false, version, customPackets, compiled = true } = {}) { 48 | return new Serializer(createProtocol(state, !isServer ? 'toServer' : 'toClient', version, customPackets, compiled), 'packet') 49 | } 50 | 51 | function createDeserializer ({ state = states.HANDSHAKING, isServer = false, version, customPackets, compiled = true, noErrorLogging = false } = {}) { 52 | return new FullPacketParser(createProtocol(state, isServer ? 'toServer' : 'toClient', version, customPackets, compiled), 'packet', noErrorLogging) 53 | } 54 | 55 | module.exports = { 56 | createSerializer, 57 | createDeserializer 58 | } 59 | -------------------------------------------------------------------------------- /examples/client_socks_proxy/client_socks_proxy.js: -------------------------------------------------------------------------------- 1 | const mc = require('minecraft-protocol') 2 | const socks = require('socks').SocksClient 3 | const ProxyAgent = require('proxy-agent') 4 | 5 | if (process.argv.length < 6 || process.argv.length > 8) { 6 | console.log('Usage : node client_socks_proxy.js [] []') 7 | process.exit(1) 8 | } 9 | 10 | const proxyHost = process.argv[4] 11 | const proxyPort = process.argv[5] 12 | 13 | // This part tests the proxy 14 | // You can comment out this part if you know what you are doing 15 | require('http').get({ 16 | method: 'GET', 17 | host: 'ifconfig.me', 18 | path: '/', 19 | agent: new ProxyAgent({ protocol: 'socks5:', host: proxyHost, port: proxyPort }) 20 | }, (res) => { 21 | if (res.statusCode === 200) { 22 | process.stdout.write('Proxy ok ip: ') 23 | res.pipe(process.stdout) 24 | res.on('close', () => { 25 | process.stdout.write('\nProxy Connection closed\n') 26 | }) 27 | } else { 28 | throw Error('Proxy not working') 29 | } 30 | }) 31 | 32 | const client = mc.createClient({ 33 | connect: client => { 34 | socks.createConnection({ 35 | proxy: { 36 | host: proxyHost, 37 | port: parseInt(proxyPort), 38 | type: 5 39 | }, 40 | command: 'connect', 41 | destination: { 42 | host: process.argv[2], 43 | port: parseInt(process.argv[3]) 44 | } 45 | }, (err, info) => { 46 | if (err) { 47 | console.log(err) 48 | return 49 | } 50 | 51 | client.setSocket(info.socket) 52 | client.emit('connect') 53 | }) 54 | }, 55 | agent: new ProxyAgent({ protocol: 'socks5:', host: proxyHost, port: proxyPort }), 56 | username: process.argv[6] ? process.argv[6] : 'echo', 57 | password: process.argv[7] 58 | }) 59 | 60 | client.on('connect', function () { 61 | console.info('connected') 62 | }) 63 | client.on('disconnect', function (packet) { 64 | console.log('disconnected: ' + packet.reason) 65 | }) 66 | client.on('end', function () { 67 | console.log('Connection lost') 68 | }) 69 | client.on('error', function (error) { 70 | console.log('Client Error', error) 71 | }) 72 | client.on('kick_disconnect', (reason) => { 73 | console.log('Kicked for reason', reason) 74 | }) 75 | client.on('chat', function (packet) { 76 | const jsonMsg = JSON.parse(packet.message) 77 | if (jsonMsg.translate === 'chat.type.announcement' || jsonMsg.translate === 'chat.type.text') { 78 | const username = jsonMsg.with[0].text 79 | const msg = jsonMsg.with[1] 80 | if (username === client.username) return 81 | client.write('chat', { message: msg }) 82 | } 83 | }) 84 | -------------------------------------------------------------------------------- /src/ping.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Client = require('./client') 4 | const states = require('./states') 5 | const tcpDns = require('./client/tcp_dns') 6 | 7 | module.exports = cbPing 8 | 9 | function cbPing (options, cb) { 10 | const pingPromise = ping(options) 11 | if (cb) { 12 | pingPromise.then((d) => { 13 | cb(null, d) 14 | }).catch((err) => { 15 | cb(err, null) 16 | }) 17 | } 18 | return pingPromise 19 | }; 20 | 21 | function ping (options) { 22 | options.host = options.host || 'localhost' 23 | options.port = options.port || 25565 24 | const optVersion = options.version || require('./version').defaultVersion 25 | const mcData = require('minecraft-data')(optVersion) 26 | const version = mcData.version 27 | options.majorVersion = version.majorVersion 28 | options.protocolVersion = version.version 29 | let closeTimer = null 30 | options.closeTimeout = options.closeTimeout || 120 * 1000 31 | options.noPongTimeout = options.noPongTimeout || 5 * 1000 32 | 33 | const client = new Client(false, version.minecraftVersion) 34 | return new Promise((resolve, reject) => { 35 | client.on('error', function (err) { 36 | clearTimeout(closeTimer) 37 | client.end() 38 | reject(err) 39 | }) 40 | client.once('server_info', function (packet) { 41 | const data = JSON.parse(packet.response) 42 | const start = Date.now() 43 | const maxTime = setTimeout(() => { 44 | clearTimeout(closeTimer) 45 | client.end() 46 | resolve(data) 47 | }, options.noPongTimeout) 48 | client.once('ping', function (packet) { 49 | data.latency = Date.now() - start 50 | clearTimeout(maxTime) 51 | clearTimeout(closeTimer) 52 | client.end() 53 | resolve(data) 54 | }) 55 | client.write('ping', { time: [0, 0] }) 56 | }) 57 | client.on('state', function (newState) { 58 | if (newState === states.STATUS) { client.write('ping_start', {}) } 59 | }) 60 | // TODO: refactor with src/client/setProtocol.js 61 | client.on('connect', function () { 62 | client.write('set_protocol', { 63 | protocolVersion: options.protocolVersion, 64 | serverHost: options.host, 65 | serverPort: options.port, 66 | nextState: 1 67 | }) 68 | client.state = states.STATUS 69 | }) 70 | // timeout against servers that never reply while keeping 71 | // the connection open and alive. 72 | closeTimer = setTimeout(function () { 73 | client.end() 74 | reject(new Error('ETIMEDOUT')) 75 | }, options.closeTimeout) 76 | tcpDns(client, options) 77 | options.connect(client) 78 | }) 79 | }; 80 | -------------------------------------------------------------------------------- /test/cyclePacketTest.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | // Tests packet serialization/deserialization from with raw binary from minecraft-packets 3 | const { createSerializer, createDeserializer, states, supportedVersions } = require('minecraft-protocol') 4 | const mcPackets = require('minecraft-packets') 5 | const assert = require('assert') 6 | 7 | const makeClientSerializer = version => createSerializer({ state: states.PLAY, version, isServer: true }) 8 | const makeClientDeserializer = version => createDeserializer({ state: states.PLAY, version }) 9 | 10 | for (const supportedVersion of supportedVersions) { 11 | let serializer, deserializer, data 12 | const mcData = require('minecraft-data')(supportedVersion) 13 | const version = mcData.version 14 | 15 | function convertBufferToObject (buffer) { 16 | return deserializer.parsePacketBuffer(buffer) 17 | } 18 | 19 | function convertObjectToBuffer (object) { 20 | return serializer.createPacketBuffer(object) 21 | } 22 | 23 | function testBuffer (buffer, [packetName, packetIx]) { 24 | const parsed = convertBufferToObject(buffer).data 25 | const parsedBuffer = convertObjectToBuffer(parsed) 26 | const areEq = buffer.equals(parsedBuffer) 27 | if (!areEq) { 28 | console.log(`Error when testing ${+packetIx + 1} ${packetName} packet`) 29 | console.direct(parsed, { depth: null }) 30 | console.log('original buffer', buffer.toString('hex')) 31 | console.log('cycled buffer', parsedBuffer.toString('hex')) 32 | } 33 | assert.strictEqual(areEq, true, `Error when testing ${+packetIx + 1} ${packetName} packet`) 34 | } 35 | describe(`Test cycle packet for version ${supportedVersion}v`, () => { 36 | before(() => { 37 | serializer = makeClientSerializer(version.minecraftVersion) 38 | deserializer = makeClientDeserializer(version.minecraftVersion) 39 | }) 40 | data = mcPackets.pc[version.minecraftVersion] 41 | it('Has packet data', () => { 42 | if (data === undefined) { 43 | // many version do not have data, so print a log for now 44 | // assert when most versions have packet data 45 | console.log(`Version ${version.minecraftVersion} has no packet dump.`) 46 | } 47 | }) 48 | // server -> client 49 | if (data !== undefined) { 50 | Object.entries(data['from-server']).forEach(([packetName, packetData]) => { 51 | it(`${packetName} packet`, function () { 52 | if (packetName === 'sound_effect') return this.skip() // sound_effect structure is out of date in minecraft-packets 53 | for (const i in packetData) { 54 | testBuffer(packetData[i].raw, [packetName, i]) 55 | } 56 | }) 57 | }) 58 | } 59 | }) 60 | } 61 | -------------------------------------------------------------------------------- /src/client/autoVersion.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const ping = require('../ping') 4 | const debug = require('debug')('minecraft-protocol') 5 | const states = require('../states') 6 | const minecraftData = require('minecraft-data') 7 | 8 | module.exports = function (client, options) { 9 | client.wait_connect = true // don't let src/client/setProtocol proceed on socket 'connect' until 'connect_allowed' 10 | debug('pinging', options.host) 11 | // TODO: use 0xfe ping instead for better compatibility/performance? https://github.com/deathcap/node-minecraft-ping 12 | ping(options, function (err, response) { 13 | if (err) { return client.emit('error', err) } 14 | debug('ping response', response) 15 | // TODO: could also use ping pre-connect to save description, type, max players, etc. 16 | const motd = response.description 17 | debug('Server description:', motd) // TODO: save 18 | 19 | // Pass server-reported version to protocol handler 20 | // The version string is interpreted by https://github.com/PrismarineJS/node-minecraft-data 21 | const brandedMinecraftVersion = response.version.name // 1.8.9, 1.7.10 22 | const protocolVersion = response.version.protocol// 47, 5 23 | const guessFromName = [brandedMinecraftVersion] 24 | .concat(brandedMinecraftVersion.match(/((\d+\.)+\d+)/g) || []) 25 | .map(function (version) { 26 | return minecraftData.versionsByMinecraftVersion.pc[version] 27 | }) 28 | .filter(function (info) { return info }) 29 | .sort(function (a, b) { return b.version - a.version }) 30 | const versions = (minecraftData.postNettyVersionsByProtocolVersion.pc[protocolVersion] || []).concat(guessFromName) 31 | if (versions.length === 0) { 32 | client.emit('error', new Error(`Unsupported protocol version '${protocolVersion}'; try updating your packages with 'npm update'`)) 33 | } 34 | const minecraftVersion = versions[0].minecraftVersion 35 | 36 | debug(`Server version: ${minecraftVersion}, protocol: ${protocolVersion}`) 37 | 38 | options.version = minecraftVersion 39 | options.protocolVersion = protocolVersion 40 | 41 | // Reinitialize client object with new version TODO: move out of its constructor? 42 | client.version = minecraftVersion 43 | client.state = states.HANDSHAKING 44 | 45 | // Let other plugins such as Forge/FML (modinfo) respond to the ping response 46 | if (client.autoVersionHooks) { 47 | client.autoVersionHooks.forEach((hook) => { 48 | hook(response, client, options) 49 | }) 50 | } 51 | 52 | // Finished configuring client object, let connection proceed 53 | client.emit('connect_allowed') 54 | client.wait_connect = false 55 | }) 56 | return client 57 | } 58 | -------------------------------------------------------------------------------- /src/server.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const net = require('net') 4 | const EventEmitter = require('events').EventEmitter 5 | const Client = require('./client') 6 | const states = require('./states') 7 | const nbt = require('prismarine-nbt') 8 | const { createSerializer } = require('./transforms/serializer') 9 | 10 | class Server extends EventEmitter { 11 | constructor (version, customPackets, hideErrors = false) { 12 | super() 13 | this.version = version 14 | this.socketServer = null 15 | this.cipher = null 16 | this.decipher = null 17 | this.clients = {} 18 | this.customPackets = customPackets 19 | this.hideErrors = hideErrors 20 | this.serializer = createSerializer({ state: 'play', isServer: true, version, customPackets }) 21 | } 22 | 23 | listen (port, host) { 24 | const self = this 25 | let nextId = 0 26 | self.socketServer = net.createServer() 27 | self.socketServer.on('connection', socket => { 28 | const client = new Client(true, this.version, this.customPackets, this.hideErrors) 29 | client._end = client.end 30 | client.end = function end (endReason, fullReason) { 31 | if (client.state === states.PLAY) { 32 | fullReason ||= client._supportFeature('chatPacketsUseNbtComponents') 33 | ? nbt.comp({ text: nbt.string(endReason) }) 34 | : JSON.stringify({ text: endReason }) 35 | client.write('kick_disconnect', { reason: fullReason }) 36 | } else if (client.state === states.LOGIN) { 37 | fullReason ||= JSON.stringify({ text: endReason }) 38 | client.write('disconnect', { reason: fullReason }) 39 | } 40 | client._end(endReason) 41 | } 42 | client.id = nextId++ 43 | self.clients[client.id] = client 44 | client.on('end', function () { 45 | delete self.clients[client.id] 46 | }) 47 | client.setSocket(socket) 48 | self.emit('connection', client) 49 | }) 50 | self.socketServer.on('error', function (err) { 51 | self.emit('error', err) 52 | }) 53 | self.socketServer.on('close', function () { 54 | self.emit('close') 55 | }) 56 | self.socketServer.on('listening', function () { 57 | self.emit('listening') 58 | }) 59 | self.socketServer.listen(port, host) 60 | } 61 | 62 | close () { 63 | Object.keys(this.clients).forEach(clientId => { 64 | const client = this.clients[clientId] 65 | client.end('ServerShutdown') 66 | }) 67 | this.socketServer.close() 68 | } 69 | 70 | writeToClients (clients, name, params) { 71 | if (clients.length === 0) return 72 | const buffer = this.serializer.createPacketBuffer({ name, params }) 73 | clients.forEach(client => client.writeRaw(buffer)) 74 | } 75 | } 76 | 77 | module.exports = Server 78 | -------------------------------------------------------------------------------- /examples/server_helloworld/server_helloworld.js: -------------------------------------------------------------------------------- 1 | const mc = require('minecraft-protocol') 2 | 3 | const options = { 4 | 'online-mode': true, 5 | version: '1.16' 6 | } 7 | 8 | const server = mc.createServer(options) 9 | const mcData = require('minecraft-data')(server.version) 10 | const loginPacket = mcData.loginPacket 11 | const nbt = require('prismarine-nbt') 12 | 13 | // Global chat index counter for 1.21.5+ 14 | let nextChatIndex = 1 15 | 16 | function chatText (text) { 17 | return mcData.supportFeature('chatPacketsUseNbtComponents') 18 | ? nbt.comp({ text: nbt.string(text) }) 19 | : JSON.stringify({ text }) 20 | } 21 | 22 | server.on('playerJoin', function (client) { 23 | const addr = client.socket.remoteAddress 24 | console.log('Incoming connection', '(' + addr + ')') 25 | 26 | client.on('end', function () { 27 | console.log('Connection closed', '(' + addr + ')') 28 | }) 29 | 30 | client.on('error', function (error) { 31 | console.log('Error:', error) 32 | }) 33 | 34 | // send init data so client will start rendering world 35 | client.write('login', { 36 | entityId: client.id, 37 | isHardcore: false, 38 | gameMode: 0, 39 | previousGameMode: 1, 40 | worldNames: loginPacket.worldNames, 41 | dimensionCodec: loginPacket.dimensionCodec, 42 | dimension: loginPacket.dimension, 43 | worldName: 'minecraft:overworld', 44 | hashedSeed: [0, 0], 45 | maxPlayers: server.maxPlayers, 46 | viewDistance: 10, 47 | reducedDebugInfo: false, 48 | enableRespawnScreen: true, 49 | isDebug: false, 50 | isFlat: false 51 | }) 52 | 53 | client.write('position', { 54 | x: 0, 55 | y: 1.62, 56 | z: 0, 57 | yaw: 0, 58 | pitch: 0, 59 | flags: 0x00 60 | }) 61 | 62 | const message = { 63 | translate: 'chat.type.announcement', 64 | with: [ 65 | 'Server', 66 | 'Hello, world!' 67 | ] 68 | } 69 | if (mcData.supportFeature('signedChat')) { 70 | client.write('player_chat', { 71 | globalIndex: nextChatIndex++, 72 | plainMessage: message, 73 | signedChatContent: '', 74 | unsignedChatContent: chatText(message), 75 | type: mcData.supportFeature('chatTypeIsHolder') ? { chatType: 1 } : 0, 76 | senderUuid: 'd3527a0b-bc03-45d5-a878-2aafdd8c8a43', // random 77 | senderName: JSON.stringify({ text: 'me' }), 78 | senderTeam: undefined, 79 | timestamp: Date.now(), 80 | salt: 0n, 81 | signature: mcData.supportFeature('useChatSessions') ? undefined : Buffer.alloc(0), 82 | previousMessages: [], 83 | filterType: 0, 84 | networkName: JSON.stringify({ text: 'me' }) 85 | }) 86 | } else { 87 | client.write('chat', { message: JSON.stringify({ text: message }), position: 0, sender: 'me' }) 88 | } 89 | }) 90 | 91 | server.on('error', function (error) { 92 | console.log('Error:', error) 93 | }) 94 | 95 | server.on('listening', function () { 96 | console.log('Server listening on port', server.socketServer.address().port) 97 | }) 98 | -------------------------------------------------------------------------------- /src/client/microsoftAuth.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const { Authflow: PrismarineAuth, Titles } = require('prismarine-auth') 3 | const minecraftFolderPath = require('minecraft-folder-path') 4 | const debug = require('debug')('minecraft-protocol') 5 | const { RealmAPI } = require('prismarine-realms') 6 | 7 | function validateOptions (options) { 8 | if (!options.profilesFolder) { 9 | options.profilesFolder = path.join(minecraftFolderPath, 'nmp-cache') 10 | } 11 | if (options.authTitle === undefined) { 12 | options.authTitle = Titles.MinecraftNintendoSwitch 13 | options.deviceType = 'Nintendo' 14 | options.flow = 'live' 15 | } 16 | } 17 | 18 | async function authenticate (client, options) { 19 | validateOptions(options) 20 | 21 | if (!client.authflow) client.authflow = new PrismarineAuth(options.username, options.profilesFolder, options, options.onMsaCode) 22 | const { token, entitlements, profile, certificates } = await client.authflow.getMinecraftJavaToken({ fetchProfile: true, fetchCertificates: !options.disableChatSigning }).catch(e => { 23 | if (options.password) console.warn('Sign in failed, try removing the password field\n') 24 | if (e.toString().includes('Not Found')) console.warn(`Please verify that the account ${options.username} owns Minecraft\n`) 25 | throw e 26 | }) 27 | 28 | debug('[mc] entitlements', entitlements) 29 | debug('[mc] profile', profile) 30 | 31 | if (!profile || profile.error) throw Error(`Failed to obtain profile data for ${options.username}, does the account own minecraft?`) 32 | 33 | options.haveCredentials = token !== null 34 | 35 | const session = { 36 | accessToken: token, 37 | selectedProfile: profile, 38 | availableProfile: [profile] 39 | } 40 | Object.assign(client, certificates) 41 | client.session = session 42 | client.username = profile.name 43 | 44 | options.accessToken = token 45 | client.emit('session', session) 46 | options.connect(client) 47 | } 48 | 49 | async function realmAuthenticate (client, options) { 50 | validateOptions(options) 51 | 52 | client.authflow = new PrismarineAuth(options.username, options.profilesFolder, options, options.onMsaCode) 53 | 54 | const api = RealmAPI.from(client.authflow, 'java') 55 | const realms = await api.getRealms() 56 | 57 | debug('realms', realms) 58 | 59 | if (!realms || !realms.length) throw Error('Couldn\'t find any Realms for the authenticated account') 60 | 61 | let realm 62 | 63 | if (options.realms.realmId) { 64 | realm = realms.find(e => e.id === Number(options.realms.realmId)) 65 | } else if (options.realms.pickRealm) { 66 | if (typeof options.realms.pickRealm !== 'function') throw Error('realms.pickRealm must be a function') 67 | realm = await options.realms.pickRealm(realms) 68 | } 69 | 70 | if (!realm) throw Error('Couldn\'t find a Realm to connect to. Authenticated account must be the owner or has been invited to the Realm.') 71 | 72 | const { host, port } = await realm.getAddress() 73 | 74 | debug('realms connection', { host, port }) 75 | 76 | options.host = host 77 | options.port = port 78 | } 79 | 80 | module.exports = { 81 | authenticate, 82 | realmAuthenticate 83 | } 84 | -------------------------------------------------------------------------------- /src/server/ping.js: -------------------------------------------------------------------------------- 1 | const endianToggle = require('endian-toggle') 2 | 3 | module.exports = function (client, server, { beforePing = null, version, fallbackVersion }) { 4 | client.once('ping_start', onPing) 5 | client.once('legacy_server_list_ping', onLegacyPing) 6 | 7 | function onPing () { 8 | let responseVersion = { 9 | name: server.mcversion.minecraftVersion, 10 | protocol: server.mcversion.version 11 | } 12 | 13 | if (version === false) { 14 | let minecraftData = require('minecraft-data')(client.protocolVersion) 15 | if (!minecraftData && fallbackVersion !== undefined) { 16 | minecraftData = require('minecraft-data')(fallbackVersion) 17 | } 18 | if (minecraftData) { 19 | responseVersion = { 20 | name: minecraftData.version.minecraftVersion, 21 | protocol: minecraftData.version.version 22 | } 23 | } else { 24 | responseVersion = { 25 | name: client.version, 26 | protocol: client.protocolVersion 27 | } 28 | } 29 | } 30 | 31 | const response = { 32 | version: responseVersion, 33 | players: { 34 | max: server.maxPlayers, 35 | online: server.playerCount, 36 | sample: [] 37 | }, 38 | description: server.motdMsg ?? { text: server.motd }, 39 | favicon: server.favicon 40 | } 41 | 42 | function answerToPing (err, response) { 43 | if (err) return 44 | if (response === false) { 45 | client.socket.destroy() 46 | } else { 47 | client.write('server_info', { response: JSON.stringify(response) }) 48 | } 49 | } 50 | 51 | if (beforePing) { 52 | if (beforePing.length > 2) { 53 | beforePing(response, client, answerToPing) 54 | } else { 55 | answerToPing(null, beforePing(response, client) || response) 56 | } 57 | } else { 58 | answerToPing(null, response) 59 | } 60 | 61 | client.once('ping', function (packet) { 62 | client.write('ping', { time: packet.time }) 63 | client.end() 64 | }) 65 | } 66 | 67 | function onLegacyPing (packet) { 68 | if (packet.payload === 1) { 69 | const pingVersion = 1 70 | sendPingResponse('\xa7' + [pingVersion, server.mcversion.version, server.mcversion.minecraftVersion, 71 | server.motd, server.playerCount.toString(), server.maxPlayers.toString()].join('\0')) 72 | } else { 73 | // ping type 0 74 | sendPingResponse([server.motd, server.playerCount.toString(), server.maxPlayers.toString()].join('\xa7')) 75 | } 76 | 77 | function sendPingResponse (responseString) { 78 | function utf16be (s) { 79 | return endianToggle(Buffer.from(s, 'utf16le'), 16) 80 | } 81 | 82 | const responseBuffer = utf16be(responseString) 83 | 84 | const length = responseString.length // UCS2 characters, not bytes 85 | const lengthBuffer = Buffer.alloc(2) 86 | lengthBuffer.writeUInt16BE(length) 87 | 88 | const raw = Buffer.concat([Buffer.from('ff', 'hex'), lengthBuffer, responseBuffer]) 89 | 90 | // client.writeRaw(raw); // not raw enough, it includes length 91 | client.socket.write(raw) 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/createClient.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const DefaultClientImpl = require('./client') 4 | const assert = require('assert') 5 | 6 | const encrypt = require('./client/encrypt') 7 | const keepalive = require('./client/keepalive') 8 | const compress = require('./client/compress') 9 | const auth = require('./client/mojangAuth') 10 | const microsoftAuth = require('./client/microsoftAuth') 11 | const setProtocol = require('./client/setProtocol') 12 | const play = require('./client/play') 13 | const tcpDns = require('./client/tcp_dns') 14 | const autoVersion = require('./client/autoVersion') 15 | const pluginChannels = require('./client/pluginChannels') 16 | const versionChecking = require('./client/versionChecking') 17 | const uuid = require('./datatypes/uuid') 18 | 19 | module.exports = createClient 20 | 21 | function createClient (options) { 22 | assert.ok(options, 'options is required') 23 | assert.ok(options.username, 'username is required') 24 | if (!options.version && !options.realms) { options.version = false } 25 | if (options.realms && options.auth !== 'microsoft') throw new Error('Currently Realms can only be joined with auth: "microsoft"') 26 | 27 | // TODO: avoid setting default version if autoVersion is enabled 28 | const optVersion = options.version || require('./version').defaultVersion 29 | const mcData = require('minecraft-data')(optVersion) 30 | if (!mcData) throw new Error(`unsupported protocol version: ${optVersion}`) 31 | const version = mcData.version 32 | options.majorVersion = version.majorVersion 33 | options.protocolVersion = version.version 34 | const hideErrors = options.hideErrors || false 35 | const Client = options.Client || DefaultClientImpl 36 | 37 | const client = new Client(false, version.minecraftVersion, options.customPackets, hideErrors) 38 | 39 | tcpDns(client, options) 40 | if (options.auth instanceof Function) { 41 | options.auth(client, options) 42 | onReady() 43 | } else { 44 | switch (options.auth) { 45 | case 'mojang': 46 | console.warn('[deprecated] mojang auth servers no longer accept mojang accounts to login. convert your account.\nhttps://help.minecraft.net/hc/en-us/articles/4403181904525-How-to-Migrate-Your-Mojang-Account-to-a-Microsoft-Account') 47 | auth(client, options) 48 | onReady() 49 | break 50 | case 'microsoft': 51 | if (options.realms) { 52 | microsoftAuth.realmAuthenticate(client, options).then(() => microsoftAuth.authenticate(client, options)).catch((err) => client.emit('error', err)).then(onReady) 53 | } else { 54 | microsoftAuth.authenticate(client, options).catch((err) => client.emit('error', err)) 55 | onReady() 56 | } 57 | break 58 | case 'offline': 59 | default: 60 | client.username = options.username 61 | client.uuid = uuid.nameToMcOfflineUUID(client.username) 62 | options.auth = 'offline' 63 | options.connect(client) 64 | onReady() 65 | break 66 | } 67 | } 68 | 69 | function onReady () { 70 | if (options.version === false) autoVersion(client, options) 71 | setProtocol(client, options) 72 | keepalive(client, options) 73 | encrypt(client, options) 74 | play(client, options) 75 | compress(client, options) 76 | pluginChannels(client, options) 77 | versionChecking(client, options) 78 | } 79 | 80 | return client 81 | } 82 | -------------------------------------------------------------------------------- /.github/CROSS_REPO_TRIGGER.md: -------------------------------------------------------------------------------- 1 | # Cross-Repository Workflow Trigger Setup 2 | 3 | This document explains how to set up cross-repository workflow triggering between the minecraft-data repository and this repository. 4 | 5 | ## Overview 6 | 7 | The workflow `update-from-minecraft-data.yml` can be triggered from the minecraft-data repository in two ways: 8 | 9 | 1. **Manual Workflow Dispatch** - Triggered manually or programmatically 10 | 2. **Repository Dispatch** - Triggered via webhook/API call 11 | 12 | ## Setup in minecraft-data repository 13 | 14 | ### Method 1: Workflow Dispatch (Recommended) 15 | 16 | Add this step to a workflow in the minecraft-data repository: 17 | 18 | ```yaml 19 | - name: Trigger update in node-minecraft-protocol 20 | uses: actions/github-script@v7 21 | with: 22 | github-token: ${{ secrets.CROSS_REPO_TOKEN }} 23 | script: | 24 | await github.rest.actions.createWorkflowDispatch({ 25 | owner: 'extremeheat', 26 | repo: 'node-minecraft-protocol', 27 | workflow_id: 'update-from-minecraft-data.yml', 28 | ref: 'master', // or the target branch 29 | inputs: { 30 | trigger_source: 'minecraft-data', 31 | trigger_reason: 'data_update', 32 | data_version: '${{ steps.get_version.outputs.version }}' // or your version variable 33 | } 34 | }); 35 | ``` 36 | 37 | ### Method 2: Repository Dispatch 38 | 39 | ```yaml 40 | - name: Trigger update in node-minecraft-protocol 41 | uses: actions/github-script@v7 42 | with: 43 | github-token: ${{ secrets.CROSS_REPO_TOKEN }} 44 | script: | 45 | await github.rest.repos.createDispatchEvent({ 46 | owner: 'extremeheat', 47 | repo: 'node-minecraft-protocol', 48 | event_type: 'minecraft-data-update', 49 | client_payload: { 50 | repository: 'minecraft-data', 51 | reason: 'data_update', 52 | version: '${{ steps.get_version.outputs.version }}' 53 | } 54 | }); 55 | ``` 56 | 57 | ## Required Secrets 58 | 59 | You need to create a Personal Access Token (PAT) with the following permissions: 60 | - `repo` scope (for private repositories) 61 | - `public_repo` scope (for public repositories) 62 | - `actions:write` permission 63 | 64 | Add this token as a secret named `CROSS_REPO_TOKEN` in the minecraft-data repository. 65 | 66 | ## Token Setup Steps 67 | 68 | 1. Go to GitHub Settings > Developer settings > Personal access tokens > Tokens (classic) 69 | 2. Generate a new token with appropriate permissions 70 | 3. Add the token as `CROSS_REPO_TOKEN` secret in minecraft-data repository settings 71 | 72 | ## Customizing the Updator Script 73 | 74 | The updator script (`.github/helper/updator.js`) can be customized to: 75 | 76 | - Download and process minecraft-data updates 77 | - Update protocol definitions 78 | - Run tests to verify compatibility 79 | - Create pull requests for review 80 | - Send notifications 81 | 82 | ## Testing 83 | 84 | You can test the workflow manually by: 85 | 86 | 1. Going to the Actions tab in this repository 87 | 2. Selecting "Update from minecraft-data" workflow 88 | 3. Clicking "Run workflow" 89 | 4. Providing test inputs 90 | 91 | ## Security Considerations 92 | 93 | - Use repository secrets for sensitive tokens 94 | - Limit token permissions to minimum required 95 | - Consider using short-lived tokens or GitHub Apps for enhanced security 96 | - Review and approve automatic commits/PRs if needed 97 | -------------------------------------------------------------------------------- /examples/server/server.js: -------------------------------------------------------------------------------- 1 | const mc = require('minecraft-protocol') 2 | const nbt = require('prismarine-nbt') 3 | 4 | const options = { 5 | motd: 'Vox Industries', 6 | 'max-players': 127, 7 | port: 25565, 8 | 'online-mode': false 9 | } 10 | 11 | const server = mc.createServer(options) 12 | const mcData = require('minecraft-data')(server.version) 13 | const loginPacket = mcData.loginPacket 14 | 15 | // Global chat index counter for 1.21.5+ 16 | let nextChatIndex = 1 17 | 18 | function chatText (text) { 19 | return mcData.supportFeature('chatPacketsUseNbtComponents') 20 | ? nbt.comp({ text: nbt.string(text) }) 21 | : JSON.stringify({ text }) 22 | } 23 | 24 | server.on('playerJoin', function (client) { 25 | broadcast(client.username + ' joined the game.') 26 | const addr = client.socket.remoteAddress + ':' + client.socket.remotePort 27 | console.log(client.username + ' connected', '(' + addr + ')') 28 | 29 | client.on('end', function () { 30 | broadcast(client.username + ' left the game.', client) 31 | console.log(client.username + ' disconnected', '(' + addr + ')') 32 | }) 33 | 34 | // send init data so client will start rendering world 35 | client.write('login', { 36 | ...loginPacket, 37 | enforceSecureChat: false, 38 | entityId: client.id, 39 | isHardcore: false, 40 | gameMode: 0, 41 | previousGameMode: 1, 42 | hashedSeed: [0, 0], 43 | maxPlayers: server.maxPlayers, 44 | viewDistance: 10, 45 | reducedDebugInfo: false, 46 | enableRespawnScreen: true, 47 | isDebug: false, 48 | isFlat: false 49 | }) 50 | client.write('position', { 51 | x: 0, 52 | y: 256, 53 | z: 0, 54 | yaw: 0, 55 | pitch: 0, 56 | flags: 0x00 57 | }) 58 | 59 | function handleChat (data) { 60 | const message = '<' + client.username + '>' + ' ' + data.message 61 | broadcast(message, null, client.username) 62 | console.log(message) 63 | } 64 | client.on('chat', handleChat) // pre-1.19 65 | client.on('chat_message', handleChat) // post 1.19 66 | }) 67 | 68 | server.on('error', function (error) { 69 | console.log('Error:', error) 70 | }) 71 | 72 | server.on('listening', function () { 73 | console.log('Server listening on port', server.socketServer.address().port) 74 | }) 75 | 76 | function sendBroadcastMessage (server, clients, message, sender) { 77 | if (mcData.supportFeature('signedChat')) { 78 | server.writeToClients(clients, 'player_chat', { 79 | globalIndex: nextChatIndex++, 80 | plainMessage: message, 81 | signedChatContent: '', 82 | unsignedChatContent: chatText(message), 83 | type: mcData.supportFeature('chatTypeIsHolder') ? { chatType: 1 } : 0, 84 | senderUuid: 'd3527a0b-bc03-45d5-a878-2aafdd8c8a43', // random 85 | senderName: JSON.stringify({ text: sender }), 86 | senderTeam: undefined, 87 | timestamp: Date.now(), 88 | salt: 0n, 89 | signature: mcData.supportFeature('useChatSessions') ? undefined : Buffer.alloc(0), 90 | previousMessages: [], 91 | filterType: 0, 92 | networkName: JSON.stringify({ text: sender }) 93 | }) 94 | } else { 95 | server.writeToClients(clients, 'chat', { message: JSON.stringify({ text: message }), position: 0, sender: sender || '0' }) 96 | } 97 | } 98 | 99 | function broadcast (message, exclude, username) { 100 | sendBroadcastMessage(server, Object.values(server.clients).filter(client => client !== exclude), message) 101 | } 102 | -------------------------------------------------------------------------------- /src/client/encrypt.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const crypto = require('crypto') 4 | const debug = require('debug')('minecraft-protocol') 5 | const yggdrasil = require('yggdrasil') 6 | const { concat } = require('../transforms/binaryStream') 7 | 8 | module.exports = function (client, options) { 9 | const yggdrasilServer = yggdrasil.server({ agent: options.agent, host: options.sessionServer || 'https://sessionserver.mojang.com' }) 10 | client.once('encryption_begin', onEncryptionKeyRequest) 11 | 12 | function onEncryptionKeyRequest (packet) { 13 | crypto.randomBytes(16, gotSharedSecret) 14 | 15 | function gotSharedSecret (err, sharedSecret) { 16 | if (err) { 17 | debug(err) 18 | client.emit('error', err) 19 | client.end('encryptionSecretError') 20 | return 21 | } 22 | if (options.haveCredentials) { 23 | joinServerRequest(onJoinServerResponse) 24 | } else { 25 | if (packet.serverId !== '-') { 26 | debug('This server appears to be an online server and you are providing no password, the authentication will probably fail') 27 | } 28 | sendEncryptionKeyResponse() 29 | } 30 | 31 | function onJoinServerResponse (err) { 32 | if (err) { 33 | client.emit('error', err) 34 | client.end('encryptionLoginError') 35 | } else { 36 | sendEncryptionKeyResponse() 37 | } 38 | } 39 | 40 | function joinServerRequest (cb) { 41 | yggdrasilServer.join(options.accessToken, client.session.selectedProfile.id, 42 | packet.serverId, sharedSecret, packet.publicKey, cb) 43 | } 44 | 45 | function sendEncryptionKeyResponse () { 46 | const mcData = require('minecraft-data')(client.version) 47 | 48 | const pubKey = mcPubKeyToPem(packet.publicKey) 49 | const encryptedSharedSecretBuffer = crypto.publicEncrypt({ key: pubKey, padding: crypto.constants.RSA_PKCS1_PADDING }, sharedSecret) 50 | const encryptedVerifyTokenBuffer = crypto.publicEncrypt({ key: pubKey, padding: crypto.constants.RSA_PKCS1_PADDING }, packet.verifyToken) 51 | 52 | if (mcData.supportFeature('signatureEncryption')) { 53 | const salt = BigInt(Date.now()) 54 | client.write('encryption_begin', { 55 | sharedSecret: encryptedSharedSecretBuffer, 56 | hasVerifyToken: client.profileKeys == null, 57 | crypto: client.profileKeys 58 | ? { 59 | salt, 60 | messageSignature: crypto.sign('sha256WithRSAEncryption', 61 | concat('buffer', packet.verifyToken, 'i64', salt), client.profileKeys.private) 62 | } 63 | : { 64 | verifyToken: encryptedVerifyTokenBuffer 65 | } 66 | }) 67 | } else { 68 | client.write('encryption_begin', { 69 | sharedSecret: encryptedSharedSecretBuffer, 70 | verifyToken: encryptedVerifyTokenBuffer 71 | }) 72 | } 73 | client.setEncryption(sharedSecret) 74 | } 75 | } 76 | } 77 | } 78 | 79 | function mcPubKeyToPem (mcPubKeyBuffer) { 80 | let pem = '-----BEGIN PUBLIC KEY-----\n' 81 | let base64PubKey = mcPubKeyBuffer.toString('base64') 82 | const maxLineLength = 64 83 | while (base64PubKey.length > 0) { 84 | pem += base64PubKey.substring(0, maxLineLength) + '\n' 85 | base64PubKey = base64PubKey.substring(maxLineLength) 86 | } 87 | pem += '-----END PUBLIC KEY-----\n' 88 | return pem 89 | } 90 | -------------------------------------------------------------------------------- /src/client/play.js: -------------------------------------------------------------------------------- 1 | const states = require('../states') 2 | const signedChatPlugin = require('./chat') 3 | const uuid = require('uuid-1345') 4 | 5 | module.exports = function (client, options) { 6 | client.serverFeatures = {} 7 | client.on('server_data', (packet) => { 8 | client.serverFeatures = { 9 | chatPreview: packet.previewsChat, 10 | enforcesSecureChat: packet.enforcesSecureChat // in LoginPacket v>=1.20.5 11 | } 12 | }) 13 | 14 | client.once('login', (packet) => { 15 | if (packet.enforcesSecureChat) client.serverFeatures.enforcesSecureChat = packet.enforcesSecureChat 16 | const mcData = require('minecraft-data')(client.version) 17 | if (mcData.supportFeature('useChatSessions') && client.profileKeys && client.cipher && client.session.selectedProfile.id === client.uuid.replace(/-/g, '')) { 18 | client._session = { 19 | index: 0, 20 | uuid: uuid.v4fast() 21 | } 22 | 23 | client.write('chat_session_update', { 24 | sessionUUID: client._session.uuid, 25 | expireTime: client.profileKeys ? BigInt(client.profileKeys.expiresOn.getTime()) : undefined, 26 | publicKey: client.profileKeys ? client.profileKeys.public.export({ type: 'spki', format: 'der' }) : undefined, 27 | signature: client.profileKeys ? client.profileKeys.signatureV2 : undefined 28 | }) 29 | } 30 | }) 31 | 32 | client.once('success', onLogin) 33 | 34 | function onLogin (packet) { 35 | const mcData = require('minecraft-data')(client.version) 36 | client.uuid = packet.uuid 37 | client.username = packet.username 38 | 39 | if (mcData.supportFeature('hasConfigurationState')) { 40 | client.write('login_acknowledged', {}) 41 | enterConfigState(onReady) 42 | // Server can tell client to re-enter config state 43 | client.on('start_configuration', () => enterConfigState()) 44 | } else { 45 | client.state = states.PLAY 46 | onReady() 47 | } 48 | 49 | function enterConfigState (finishCb) { 50 | if (client.state === states.CONFIGURATION) return 51 | // If we are returning to the configuration state from the play state, we ahve to acknowledge it. 52 | if (client.state === states.PLAY) { 53 | client.write('configuration_acknowledged', {}) 54 | } 55 | client.state = states.CONFIGURATION 56 | client.once('select_known_packs', () => { 57 | client.write('select_known_packs', { packs: [] }) 58 | }) 59 | // Server should send finish_configuration on its own right after sending the client a dimension codec 60 | // for login (that has data about world height, world gen, etc) after getting a login success from client 61 | client.once('finish_configuration', () => { 62 | client.write('finish_configuration', {}) 63 | client.state = states.PLAY 64 | finishCb?.() 65 | }) 66 | } 67 | 68 | function onReady () { 69 | if (mcData.supportFeature('signedChat')) { 70 | if (options.disableChatSigning && client.serverFeatures.enforcesSecureChat) { 71 | throw new Error('"disableChatSigning" was enabled in client options, but server is enforcing secure chat') 72 | } 73 | signedChatPlugin(client, options) 74 | } else { 75 | client.on('chat', (packet) => { 76 | client.emit(packet.position === 0 ? 'playerChat' : 'systemChat', { 77 | formattedMessage: packet.message, 78 | sender: packet.sender, 79 | positionId: packet.position, 80 | verified: false 81 | }) 82 | }) 83 | } 84 | 85 | function unsignedChat (message) { 86 | client.write('chat', { message }) 87 | } 88 | client.chat = client._signedChat || unsignedChat 89 | client.emit('playerJoin') 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/client/pluginChannels.js: -------------------------------------------------------------------------------- 1 | const ProtoDef = require('protodef').ProtoDef 2 | const minecraft = require('../datatypes/minecraft') 3 | const debug = require('debug')('minecraft-protocol') 4 | const nbt = require('prismarine-nbt') 5 | 6 | module.exports = function (client, options) { 7 | const mcdata = require('minecraft-data')(options.version || require('../version').defaultVersion) 8 | const channels = [] 9 | const proto = new ProtoDef(options.validateChannelProtocol ?? true) 10 | nbt.addTypesToInterpreter('big', proto) 11 | proto.addTypes(mcdata.protocol.types) 12 | proto.addTypes(minecraft) 13 | proto.addType('registerarr', [readDumbArr, writeDumbArr, sizeOfDumbArr]) 14 | 15 | client.registerChannel = registerChannel 16 | client.unregisterChannel = unregisterChannel 17 | client.writeChannel = writeChannel 18 | 19 | const above385 = mcdata.version.version >= 385 20 | if (above385) { // 1.13-pre3 (385) added Added Login Plugin Message (https://wiki.vg/Protocol_History#1.13-pre3) 21 | client.on('login_plugin_request', onLoginPluginRequest) 22 | } 23 | const channelNames = above385 ? ['minecraft:register', 'minecraft:unregister'] : ['REGISTER', 'UNREGISTER'] 24 | 25 | client.registerChannel(channelNames[0], ['registerarr', []]) 26 | client.registerChannel(channelNames[1], ['registerarr', []]) 27 | 28 | function registerChannel (name, parser, custom) { 29 | if (custom) { 30 | client.writeChannel(channelNames[0], [name]) 31 | } 32 | if (parser) proto.addType(name, parser) 33 | channels.push(name) 34 | if (channels.length === 1) { client.on('custom_payload', onCustomPayload) } 35 | } 36 | 37 | function unregisterChannel (channel, custom) { 38 | if (custom) { 39 | client.writeChannel(channelNames[1], [channel]) 40 | } 41 | const index = channels.find(function (name) { 42 | return channel === name 43 | }) 44 | if (index) { 45 | proto.types[channel] = undefined 46 | channels.splice(index, 1) 47 | if (channels.length === 0) { client.removeListener('custom_payload', onCustomPayload) } 48 | } 49 | } 50 | 51 | function onCustomPayload (packet) { 52 | const channel = channels.find(function (channel) { 53 | return channel === packet.channel 54 | }) 55 | if (channel) { 56 | if (proto.types[channel]) { 57 | try { 58 | packet.data = proto.parsePacketBuffer(channel, packet.data).data 59 | } catch (error) { 60 | client.emit('error', error) 61 | return 62 | } 63 | } 64 | debug('read custom payload ' + channel + ' ' + packet.data) 65 | client.emit(channel, packet.data) 66 | } 67 | } 68 | 69 | function onLoginPluginRequest (packet) { 70 | client.write('login_plugin_response', { // write that login plugin request is not understood, just like the Notchian client 71 | messageId: packet.messageId 72 | }) 73 | } 74 | 75 | function writeChannel (channel, params) { 76 | debug('write custom payload ' + channel + ' ' + params) 77 | client.write('custom_payload', { 78 | channel, 79 | data: proto.createPacketBuffer(channel, params) 80 | }) 81 | } 82 | 83 | function readDumbArr (buf, offset) { 84 | const ret = { 85 | value: [], 86 | size: 0 87 | } 88 | let results 89 | while (offset < buf.length) { 90 | if (buf.indexOf(0x0, offset) === -1) { results = this.read(buf, offset, 'restBuffer', {}) } else { results = this.read(buf, offset, 'cstring', {}) } 91 | ret.size += results.size 92 | ret.value.push(results.value.toString()) 93 | offset += results.size 94 | } 95 | return ret 96 | } 97 | 98 | function writeDumbArr (value, buf, offset) { 99 | // TODO: Remove trailing \0 100 | value.forEach(function (v) { 101 | offset += proto.write(v, buf, offset, 'cstring') 102 | }) 103 | return offset 104 | } 105 | 106 | function sizeOfDumbArr (value) { 107 | return value.reduce((acc, v) => acc + this.sizeOf(v, 'cstring', {}), 0) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /docs/chat.md: -------------------------------------------------------------------------------- 1 | ## About chat signing 2 | 3 | Starting in Minecraft 1.19, client messages sent to the server are signed and then broadcasted to other players. 4 | Other clients receiving a signed message can verify that a message was written by a particular player as opposed 5 | to being modified by the server. The way this is achieved is by the client asking Mojang's servers for signing keys, 6 | and the server responding with a private key that can be used to sign messages, and a public key that can be used to 7 | verify the messages. 8 | 9 | When a client connects to the server, it sends its public key to the server, which then sends that to other players 10 | that are on the server. The server also does some checks during the login procedure to authenticate the validity of 11 | the public key, to ensure it came from Mojang. This is achieved by the client sending along a signature from Mojang's 12 | servers in the login step which is the output of concatenating and signing the public key, player UUID and timestamp 13 | with a special Mojang private key specifically for signature validation. The public key used to verify this 14 | signature is public and is stored statically inside node-minecraft-protocol (src/server/constants.js). 15 | 16 | Back to the client, when other players join the server they also get a copy of the players' public key for chat verification. 17 | The clients can then verify that a message came from a client as well as do secondary checks like verifying timestamps. 18 | This feature is designed to allow players to report chat messages from other players to Mojang. When the client reports a 19 | message the contents, the sender UUID, timestamp, and signature are all sent so the Mojang server can verify the message 20 | and send it for moderator review. 21 | 22 | Note: Since the server sends the public key, it's possible that the server can spoof the key and return a fake one, so 23 | only Mojang can truly know if a message came from a client (as it stores its own copy of the clients' chat key pair). 24 | 25 | ## 1.19.1 26 | 27 | Starting with 1.19.1, instead of signing the message itself, a SHA256 hash of the message and last seen messages are 28 | signed instead. In addition, the payload of the hash is prepended with the signature of the previous message sent by the same client, 29 | creating a signed chain of chat messages. See publicly available documentation for more detailed information on this. 30 | 31 | Since chat verification happens on the client-side (as well as server side), all clients need to be kept up to date 32 | on messages from other users. Since not all messages are public (for example, a player may send a signed private message), 33 | the server can send a `chat_header` packet containing the aforementioned SHA256 hash of the message which the client 34 | can generate a signature from, and store as the last signature for that player (maintaining chain integrity). 35 | 36 | In the client, inbound player chat history is now stored in chat logs (in a 1000 length array). This allows players 37 | to search through last seen messages when reporting messages. 38 | 39 | When reporting chat messages, the chained chat functionality and chat history also securely lets Mojang get 40 | authentic message context before and after a reported message. 41 | 42 | ## Extra details 43 | 44 | ### 1.19.1 45 | 46 | When a server sends a player a message from another player, the server saves the outbound message and expects 47 | that the client will acknowledge that message, either in a outbound `chat_message` packet's lastSeen field, 48 | or in a `message_acknowledgement` packet. (If the client doesn't seen any chat_message's to the server and 49 | lots of messages pending ACK queue up, a serverbound `message_acknowledgement` packet will be sent to flush the queue.) 50 | 51 | In the server, upon reviewal of the ACK, those messages removed from the servers' pending array. If too many 52 | pending messages pile up, the client will get kicked. 53 | 54 | In nmp server, you must call `client.logSentMessageFromPeer(packet)` when the server receives a message from a player and that message gets broadcast to other players in player_chat packets. This function stores these packets so the server can then verify a player's lastSeenMessages field in inbound chat packets to ensure chain integrity (as described above). -------------------------------------------------------------------------------- /.github/helper/updator.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /** 3 | * Updator script triggered from minecraft-data repository to auto generate PR 4 | */ 5 | const fs = require('fs') 6 | const cp = require('child_process') 7 | const assert = require('assert') 8 | const github = require('gh-helpers')() 9 | const { join } = require('path') 10 | const exec = (cmd) => github.mock ? console.log('> ', cmd) : (console.log('> ', cmd), cp.execSync(cmd, { stdio: 'inherit' })) 11 | 12 | console.log('Starting update process...') 13 | // Sanitize and validate environment variables all non alpha numeric / underscore / dot 14 | const newVersion = process.env.NEW_MC_VERSION?.replace(/[^a-zA-Z0-9_.]/g, '_') 15 | const triggerBranch = process.env.MCDATA_BRANCH?.replace(/[^a-zA-Z0-9_.]/g, '_') 16 | const mcdataPrURL = process.env.MCDATA_PR_URL 17 | console.log({ newVersion, triggerBranch, mcdataPrURL }) 18 | 19 | assert(newVersion) 20 | assert(triggerBranch) 21 | 22 | async function main () { 23 | const currentSupportedPath = require.resolve('../../src/version.js') 24 | const readmePath = join(__dirname, '../../docs/README.md') 25 | const ciPath = join(__dirname, '../../.github/workflows/ci.yml') 26 | 27 | // Update the version.js 28 | const currentSupportedVersion = require('../../src/version.js') 29 | const currentContents = fs.readFileSync(currentSupportedPath, 'utf8') 30 | console.log('Current supported version:', currentContents) 31 | const newContents = currentContents.includes(newVersion) 32 | ? currentContents 33 | : currentContents 34 | .replace(`: '${currentSupportedVersion.defaultVersion}'`, `: '${newVersion}'`) 35 | .replace(`, '${currentSupportedVersion.defaultVersion}'`, `, '${currentSupportedVersion.defaultVersion}', '${newVersion}'`) 36 | 37 | // Update the README.md 38 | const currentContentsReadme = fs.readFileSync(readmePath, 'utf8') 39 | if (!currentContentsReadme.includes(newVersion)) { 40 | const newReadmeContents = currentContentsReadme.replace('\n', `, ${newVersion}\n`) 41 | fs.writeFileSync(readmePath, newReadmeContents) 42 | console.log('Updated README with new version:', newVersion) 43 | } 44 | fs.writeFileSync(currentSupportedPath, newContents) 45 | 46 | // Update the CI workflow 47 | const currentContentsCI = fs.readFileSync(ciPath, 'utf8') 48 | if (!currentContentsCI.includes(newVersion)) { 49 | const newCIContents = currentContentsCI.replace( 50 | 'run: npm install', `run: npm install 51 | - run: cd node_modules && cd minecraft-data && mv minecraft-data minecraft-data-old && git clone -b ${triggerBranch} https://github.com/PrismarineJS/minecraft-data --depth 1 && node bin/generate_data.js 52 | - run: curl -o node_modules/protodef/src/serializer.js https://raw.githubusercontent.com/extremeheat/node-protodef/refs/heads/dlog/src/serializer.js && curl -o node_modules/protodef/src/compiler.js https://raw.githubusercontent.com/extremeheat/node-protodef/refs/heads/dlog/src/compiler.js 53 | `) 54 | fs.writeFileSync(ciPath, newCIContents) 55 | console.log('Updated CI workflow with new version:', newVersion) 56 | } 57 | 58 | const branchName = 'pc' + newVersion.replace(/[^a-zA-Z0-9_]/g, '_') 59 | exec(`git checkout -b ${branchName}`) 60 | exec('git config user.name "github-actions[bot]"') 61 | exec('git config user.email "41898282+github-actions[bot]@users.noreply.github.com"') 62 | exec('git add --all') 63 | exec(`git commit -m "Update to version ${newVersion}"`) 64 | exec(`git push origin ${branchName} --force`) 65 | // createPullRequest(title: string, body: string, fromBranch: string, intoBranch?: string): Promise<{ number: number, url: string }>; 66 | const pr = await github.createPullRequest( 67 | `🎈 ${newVersion}`, 68 | `This automated PR sets up the relevant boilerplate for Minecraft version ${newVersion}. 69 | 70 | Ref: ${mcdataPrURL} 71 | 72 | * You can help contribute to this PR by opening a PR against this ${branchName} branch instead of master. 73 | `, 74 | branchName, 75 | 'master' 76 | ) 77 | console.log(`Pull request created`, pr) 78 | 79 | // Ask mineflayer to handle new update 80 | const nodeDispatchPayload = { 81 | owner: 'PrismarineJS', 82 | repo: 'mineflayer', 83 | workflow: 'handle-update.yml', 84 | branch: 'master', 85 | inputs: { 86 | new_mc_version: newVersion, 87 | mcdata_branch: triggerBranch, 88 | mcdata_pr_url: mcdataPrURL, 89 | nmp_branch: branchName, 90 | nmp_pr_url: pr.url 91 | } 92 | } 93 | console.log('Sending workflow dispatch', nodeDispatchPayload) 94 | await github.sendWorkflowDispatch(nodeDispatchPayload) 95 | } 96 | 97 | main().catch(err => { 98 | console.error('Error during update process:', err) 99 | process.exit(1) 100 | }) 101 | -------------------------------------------------------------------------------- /src/datatypes/minecraft.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const nbt = require('prismarine-nbt') 4 | const UUID = require('uuid-1345') 5 | const zlib = require('zlib') 6 | const [readVarInt, writeVarInt, sizeOfVarInt] = require('protodef').types.varint 7 | 8 | module.exports = { 9 | varlong: [readVarLong, writeVarLong, sizeOfVarLong], 10 | UUID: [readUUID, writeUUID, 16], 11 | compressedNbt: [readCompressedNbt, writeCompressedNbt, sizeOfCompressedNbt], 12 | restBuffer: [readRestBuffer, writeRestBuffer, sizeOfRestBuffer], 13 | entityMetadataLoop: [readEntityMetadata, writeEntityMetadata, sizeOfEntityMetadata], 14 | topBitSetTerminatedArray: [readTopBitSetTerminatedArray, writeTopBitSetTerminatedArray, sizeOfTopBitSetTerminatedArray] 15 | } 16 | const PartialReadError = require('protodef').utils.PartialReadError 17 | 18 | function readVarLong (buffer, offset) { 19 | return readVarInt(buffer, offset) 20 | } 21 | 22 | function writeVarLong (value, buffer, offset) { 23 | return writeVarInt(value, buffer, offset) 24 | } 25 | 26 | function sizeOfVarLong (value) { 27 | return sizeOfVarInt(value) 28 | } 29 | 30 | function readUUID (buffer, offset) { 31 | if (offset + 16 > buffer.length) { throw new PartialReadError() } 32 | return { 33 | value: UUID.stringify(buffer.slice(offset, 16 + offset)), 34 | size: 16 35 | } 36 | } 37 | 38 | function writeUUID (value, buffer, offset) { 39 | const buf = value.length === 32 ? Buffer.from(value, 'hex') : UUID.parse(value) 40 | buf.copy(buffer, offset) 41 | return offset + 16 42 | } 43 | 44 | function sizeOfNbt (value, { tagType } = { tagType: 'nbt' }) { 45 | return nbt.proto.sizeOf(value, tagType) 46 | } 47 | 48 | // Length-prefixed compressed NBT, see differences: http://wiki.vg/index.php?title=Slot_Data&diff=6056&oldid=4753 49 | function readCompressedNbt (buffer, offset) { 50 | if (offset + 2 > buffer.length) { throw new PartialReadError() } 51 | const length = buffer.readInt16BE(offset) 52 | if (length === -1) return { size: 2 } 53 | if (offset + 2 + length > buffer.length) { throw new PartialReadError() } 54 | 55 | const compressedNbt = buffer.slice(offset + 2, offset + 2 + length) 56 | 57 | const nbtBuffer = zlib.gunzipSync(compressedNbt) // TODO: async 58 | 59 | const results = nbt.proto.read(nbtBuffer, 0, 'nbt') 60 | return { 61 | size: length + 2, 62 | value: results.value 63 | } 64 | } 65 | 66 | function writeCompressedNbt (value, buffer, offset) { 67 | if (value === undefined) { 68 | buffer.writeInt16BE(-1, offset) 69 | return offset + 2 70 | } 71 | const nbtBuffer = Buffer.alloc(sizeOfNbt(value)) 72 | nbt.proto.write(value, nbtBuffer, 0, 'nbt') 73 | 74 | const compressedNbt = zlib.gzipSync(nbtBuffer) // TODO: async 75 | compressedNbt.writeUInt8(0, 9) // clear the OS field to match MC 76 | 77 | buffer.writeInt16BE(compressedNbt.length, offset) 78 | compressedNbt.copy(buffer, offset + 2) 79 | return offset + 2 + compressedNbt.length 80 | } 81 | 82 | function sizeOfCompressedNbt (value) { 83 | if (value === undefined) { return 2 } 84 | 85 | const nbtBuffer = Buffer.alloc(sizeOfNbt(value, { tagType: 'nbt' })) 86 | nbt.proto.write(value, nbtBuffer, 0, 'nbt') 87 | 88 | const compressedNbt = zlib.gzipSync(nbtBuffer) // TODO: async 89 | 90 | return 2 + compressedNbt.length 91 | } 92 | 93 | function readRestBuffer (buffer, offset) { 94 | return { 95 | value: buffer.slice(offset), 96 | size: buffer.length - offset 97 | } 98 | } 99 | 100 | function writeRestBuffer (value, buffer, offset) { 101 | value.copy(buffer, offset) 102 | return offset + value.length 103 | } 104 | 105 | function sizeOfRestBuffer (value) { 106 | return value.length 107 | } 108 | 109 | function readEntityMetadata (buffer, offset, { type, endVal }) { 110 | let cursor = offset 111 | const metadata = [] 112 | let item 113 | while (true) { 114 | if (offset + 1 > buffer.length) { throw new PartialReadError() } 115 | item = buffer.readUInt8(cursor) 116 | if (item === endVal) { 117 | return { 118 | value: metadata, 119 | size: cursor + 1 - offset 120 | } 121 | } 122 | const results = this.read(buffer, cursor, type, {}) 123 | metadata.push(results.value) 124 | cursor += results.size 125 | } 126 | } 127 | 128 | function writeEntityMetadata (value, buffer, offset, { type, endVal }) { 129 | const self = this 130 | value.forEach(function (item) { 131 | offset = self.write(item, buffer, offset, type, {}) 132 | }) 133 | buffer.writeUInt8(endVal, offset) 134 | return offset + 1 135 | } 136 | 137 | function sizeOfEntityMetadata (value, { type }) { 138 | let size = 1 139 | for (let i = 0; i < value.length; ++i) { 140 | size += this.sizeOf(value[i], type, {}) 141 | } 142 | return size 143 | } 144 | 145 | function readTopBitSetTerminatedArray (buffer, offset, { type }) { 146 | let cursor = offset 147 | const values = [] 148 | let item 149 | while (true) { 150 | if (offset + 1 > buffer.length) { throw new PartialReadError() } 151 | item = buffer.readUInt8(cursor) 152 | buffer[cursor] = buffer[cursor] & 127 // removes top bit 153 | const results = this.read(buffer, cursor, type, {}) 154 | values.push(results.value) 155 | cursor += results.size 156 | if ((item & 128) === 0) { // check if top bit is set, if not last value 157 | return { 158 | value: values, 159 | size: cursor - offset 160 | } 161 | } 162 | } 163 | } 164 | 165 | function writeTopBitSetTerminatedArray (value, buffer, offset, { type }) { 166 | const self = this 167 | let prevOffset = offset 168 | value.forEach(function (item, i) { 169 | prevOffset = offset 170 | offset = self.write(item, buffer, offset, type, {}) 171 | buffer[prevOffset] = i !== value.length - 1 ? (buffer[prevOffset] | 128) : buffer[prevOffset] // set top bit for all values but last 172 | }) 173 | return offset 174 | } 175 | 176 | function sizeOfTopBitSetTerminatedArray (value, { type }) { 177 | let size = 0 178 | for (let i = 0; i < value.length; ++i) { 179 | size += this.sizeOf(value[i], type, {}) 180 | } 181 | return size 182 | } 183 | -------------------------------------------------------------------------------- /examples/proxy/proxy.js: -------------------------------------------------------------------------------- 1 | const mc = require('minecraft-protocol') 2 | 3 | const states = mc.states 4 | function printHelpAndExit (exitCode) { 5 | console.log('usage: node proxy.js [...] ') 6 | console.log('options:') 7 | console.log(' --dump name') 8 | console.log(' print to stdout messages with the specified name.') 9 | console.log(' --dump-all') 10 | console.log(' print to stdout all messages, except those specified with -x.') 11 | console.log(' -x name') 12 | console.log(' do not print messages with this name.') 13 | console.log(' name') 14 | console.log(' a packet name as defined in protocol.json') 15 | console.log('examples:') 16 | console.log(' node proxy.js --dump-all -x keep_alive -x update_time -x entity_velocity -x rel_entity_move -x entity_look -x entity_move_look -x entity_teleport -x entity_head_rotation -x position localhost 1.8') 17 | console.log(' print all messages except for some of the most prolific.') 18 | console.log(' node examples/proxy.js --dump open_window --dump close_window --dump set_slot --dump window_items --dump craft_progress_bar --dump transaction --dump close_window --dump window_click --dump set_creative_slot --dump enchant_item localhost 1.8') 19 | console.log(' print messages relating to inventory management.') 20 | 21 | process.exit(exitCode) 22 | } 23 | 24 | if (process.argv.length < 4) { 25 | console.log('Too few arguments!') 26 | printHelpAndExit(1) 27 | } 28 | 29 | process.argv.forEach(function (val) { 30 | if (val === '-h') { 31 | printHelpAndExit(0) 32 | } 33 | }) 34 | 35 | const args = process.argv.slice(2) 36 | let host 37 | let port = 25565 38 | let version 39 | 40 | let printAllNames = false 41 | const printNameWhitelist = {} 42 | const printNameBlacklist = {}; 43 | (function () { 44 | let i = 0 45 | for (i = 0; i < args.length; i++) { 46 | const option = args[i] 47 | if (!/^-/.test(option)) break 48 | if (option === '--dump-all') { 49 | printAllNames = true 50 | continue 51 | } 52 | i++ 53 | const name = args[i] 54 | if (option === '--dump') { 55 | printNameWhitelist[name] = 'io' 56 | } else if (option === '-x') { 57 | printNameBlacklist[name] = 'io' 58 | } else { 59 | printHelpAndExit(1) 60 | } 61 | } 62 | if (!(i + 2 <= args.length && args.length <= i + 4)) printHelpAndExit(1) 63 | host = args[i++] 64 | version = args[i++] 65 | })() 66 | 67 | if (host.indexOf(':') !== -1) { 68 | port = host.substring(host.indexOf(':') + 1) 69 | host = host.substring(0, host.indexOf(':')) 70 | } 71 | 72 | const srv = mc.createServer({ 73 | 'online-mode': false, 74 | port: 25566, 75 | keepAlive: false, 76 | version 77 | }) 78 | srv.on('login', function (client) { 79 | const addr = client.socket.remoteAddress 80 | console.log('Incoming connection', '(' + addr + ')') 81 | let endedClient = false 82 | let endedTargetClient = false 83 | client.on('end', function () { 84 | endedClient = true 85 | console.log('Connection closed by client', '(' + addr + ')') 86 | if (!endedTargetClient) { targetClient.end('End') } 87 | }) 88 | client.on('error', function (err) { 89 | endedClient = true 90 | console.log('Connection error by client', '(' + addr + ')') 91 | console.log(err.stack) 92 | if (!endedTargetClient) { targetClient.end('Error') } 93 | }) 94 | const targetClient = mc.createClient({ 95 | host, 96 | port, 97 | username: client.username, 98 | keepAlive: false, 99 | version 100 | }) 101 | client.on('packet', function (data, meta) { 102 | if (targetClient.state === states.PLAY && meta.state === states.PLAY) { 103 | if (shouldDump(meta.name, 'o')) { 104 | console.log('client->server:', 105 | client.state + ' ' + meta.name + ' :', 106 | JSON.stringify(data)) 107 | } 108 | if (!endedTargetClient) { targetClient.write(meta.name, data) } 109 | } 110 | }) 111 | targetClient.on('packet', function (data, meta) { 112 | if (meta.state === states.PLAY && client.state === states.PLAY) { 113 | if (shouldDump(meta.name, 'i')) { 114 | console.log('client<-server:', 115 | targetClient.state + '.' + meta.name + ' :' + 116 | JSON.stringify(data)) 117 | } 118 | if (!endedClient) { 119 | client.write(meta.name, data) 120 | if (meta.name === 'set_compression') { 121 | client.compressionThreshold = data.threshold 122 | } // Set compression 123 | } 124 | } 125 | }) 126 | const bufferEqual = require('buffer-equal') 127 | targetClient.on('raw', function (buffer, meta) { 128 | if (client.state !== states.PLAY || meta.state !== states.PLAY) { return } 129 | const packetData = targetClient.deserializer.parsePacketBuffer(buffer).data.params 130 | const packetBuff = client.serializer.createPacketBuffer({ name: meta.name, params: packetData }) 131 | if (!bufferEqual(buffer, packetBuff)) { 132 | console.log('client<-server: Error in packet ' + meta.state + '.' + meta.name) 133 | console.log('received buffer', buffer.toString('hex')) 134 | console.log('produced buffer', packetBuff.toString('hex')) 135 | console.log('received length', buffer.length) 136 | console.log('produced length', packetBuff.length) 137 | } 138 | /* if (client.state === states.PLAY && brokenPackets.indexOf(packetId.value) !=== -1) 139 | { 140 | console.log(`client<-server: raw packet); 141 | console.log(packetData); 142 | if (!endedClient) 143 | client.writeRaw(buffer); 144 | } */ 145 | }) 146 | client.on('raw', function (buffer, meta) { 147 | if (meta.state !== states.PLAY || targetClient.state !== states.PLAY) { return } 148 | const packetData = client.deserializer.parsePacketBuffer(buffer).data.params 149 | const packetBuff = targetClient.serializer.createPacketBuffer({ name: meta.name, params: packetData }) 150 | if (!bufferEqual(buffer, packetBuff)) { 151 | console.log('client->server: Error in packet ' + meta.state + '.' + meta.name) 152 | console.log('received buffer', buffer.toString('hex')) 153 | console.log('produced buffer', packetBuff.toString('hex')) 154 | console.log('received length', buffer.length) 155 | console.log('produced length', packetBuff.length) 156 | } 157 | }) 158 | targetClient.on('end', function () { 159 | endedTargetClient = true 160 | console.log('Connection closed by server', '(' + addr + ')') 161 | if (!endedClient) { client.end('End') } 162 | }) 163 | targetClient.on('error', function (err) { 164 | endedTargetClient = true 165 | console.log('Connection error by server', '(' + addr + ') ', err) 166 | console.log(err.stack) 167 | if (!endedClient) { client.end('Error') } 168 | }) 169 | }) 170 | 171 | function shouldDump (name, direction) { 172 | if (matches(printNameBlacklist[name])) return false 173 | if (printAllNames) return true 174 | return matches(printNameWhitelist[name]) 175 | 176 | function matches (result) { 177 | return result !== undefined && result !== null && result.indexOf(direction) !== -1 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /src/client/mojangAuth.js: -------------------------------------------------------------------------------- 1 | const UUID = require('uuid-1345') 2 | const yggdrasil = require('yggdrasil') 3 | const fs = require('fs').promises 4 | const mcDefaultFolderPath = require('minecraft-folder-path') 5 | const path = require('path') 6 | 7 | const launcherDataFile = 'launcher_accounts.json' 8 | 9 | module.exports = async function (client, options) { 10 | if (!options.profilesFolder && options.profilesFolder !== false) { // not defined, but not explicitly false. fallback to default 11 | let mcFolderExists = true 12 | try { 13 | await fs.access(mcDefaultFolderPath) 14 | } catch (ignoreErr) { 15 | mcFolderExists = false 16 | } 17 | options.profilesFolder = mcFolderExists ? mcDefaultFolderPath : '.' // local folder if mc folder doesn't exist 18 | } 19 | 20 | const yggdrasilClient = yggdrasil({ agent: options.agent, host: options.authServer || 'https://authserver.mojang.com' }) 21 | const clientToken = options.clientToken || (options.session && options.session.clientToken) || (options.profilesFolder && (await getLauncherProfiles()).mojangClientToken) || UUID.v4().toString().replace(/-/g, '') 22 | const skipValidation = false || options.skipValidation 23 | options.accessToken = null 24 | options.haveCredentials = !!options.password || (clientToken != null && options.session != null) || (options.profilesFolder && !!getProfileId(await getLauncherProfiles())) 25 | 26 | async function getLauncherProfiles () { // get launcher profiles 27 | try { 28 | return JSON.parse(await fs.readFile(path.join(options.profilesFolder, launcherDataFile), 'utf8')) 29 | } catch (err) { 30 | await fs.mkdir(options.profilesFolder, { recursive: true }) 31 | await fs.writeFile(path.join(options.profilesFolder, launcherDataFile), '{}') 32 | return { accounts: {} } 33 | } 34 | } 35 | 36 | function getProfileId (auths) { 37 | try { 38 | const lowerUsername = options.username.toLowerCase() 39 | return Object.keys(auths.accounts).find(key => 40 | auths.accounts[key].username.toLowerCase() === lowerUsername || 41 | auths.accounts[key].minecraftProfile.name.toLowerCase() === lowerUsername 42 | ) 43 | } catch (err) { 44 | return false 45 | } 46 | } 47 | 48 | if (options.haveCredentials) { 49 | // make a request to get the case-correct username before connecting. 50 | const cb = function (err, session) { 51 | if (options.profilesFolder) { 52 | getLauncherProfiles().then((auths) => { 53 | if (!auths.accounts) auths.accounts = [] 54 | try { 55 | let profile = getProfileId(auths) 56 | if (err) { 57 | if (profile && auths.accounts[profile].type !== 'Xbox') { // MS accounts are deemed invalid in case someone tries to use one without specifying options.auth, but we shouldn't remove these 58 | delete auths.accounts[profile] // profile is invalid, remove 59 | } 60 | } else { // successful login 61 | if (!profile) { 62 | profile = UUID.v4().toString().replace(/-/g, '') // create new profile 63 | throw new Error('Account not found') // TODO: Find a way to calculate remoteId. Launcher ignores account entry and makes a new one if remoteId is incorrect 64 | } 65 | if (!auths.accounts[profile].remoteId) { 66 | delete auths.accounts[profile] 67 | throw new Error('Account has no remoteId') // TODO: Find a way to calculate remoteId. Launcher ignores account entry and makes a new one if remoteId is incorrect 68 | } 69 | if (!auths.mojangClientToken) { 70 | auths.mojangClientToken = clientToken 71 | } 72 | 73 | if (clientToken === auths.mojangClientToken) { // only do something when we can save a new clienttoken or they match 74 | const oldProfileObj = auths.accounts[profile] 75 | const newProfileObj = { 76 | accessToken: session.accessToken, 77 | minecraftProfile: { 78 | id: session.selectedProfile.id, 79 | name: session.selectedProfile.name 80 | }, 81 | userProperites: oldProfileObj?.userProperites ?? [], 82 | remoteId: oldProfileObj?.remoteId ?? '', 83 | username: options.username, 84 | localId: profile, 85 | type: (options.auth?.toLowerCase() === 'mojang' ? 'Mojang' : 'Xbox'), 86 | persistent: true 87 | } 88 | auths.accounts[profile] = newProfileObj 89 | } 90 | } 91 | } catch (ignoreErr) { 92 | // again, silently fail, just don't save anything 93 | } 94 | fs.writeFile(path.join(options.profilesFolder, launcherDataFile), JSON.stringify(auths, null, 2)).then(() => {}, (ignoreErr) => { 95 | // console.warn("Couldn't save tokens:\n", err) // not any error, we just don't save the file 96 | }) 97 | }, (ignoreErr) => { 98 | // console.warn("Skipped saving tokens because of error\n", err) // not any error, we just don't save the file 99 | }) 100 | } 101 | 102 | if (err) { 103 | client.emit('error', err) 104 | } else { 105 | client.session = session 106 | client.username = session.selectedProfile.name 107 | options.accessToken = session.accessToken 108 | client.emit('session', session) 109 | options.connect(client) 110 | } 111 | } 112 | 113 | if (!options.session && options.profilesFolder) { 114 | try { 115 | const auths = await getLauncherProfiles() 116 | const profile = getProfileId(auths) 117 | 118 | if (profile) { 119 | const newUsername = auths.accounts[profile].username 120 | const displayName = auths.accounts[profile].minecraftProfile.name 121 | const uuid = auths.accounts[profile].minecraftProfile.id 122 | const newProfile = { 123 | id: uuid, 124 | name: displayName 125 | } 126 | 127 | options.session = { 128 | accessToken: auths.accounts[profile].accessToken, 129 | clientToken: auths.mojangClientToken, 130 | selectedProfile: newProfile, 131 | availableProfiles: [newProfile] 132 | } 133 | options.username = newUsername 134 | } 135 | } catch (ignoreErr) { 136 | // skip the error :/ 137 | } 138 | } 139 | 140 | if (options.session) { 141 | if (!skipValidation) { 142 | yggdrasilClient.validate(options.session.accessToken, function (err) { 143 | if (!err) { cb(null, options.session) } else { 144 | yggdrasilClient.refresh(options.session.accessToken, options.session.clientToken, function (err, accessToken, data) { 145 | if (!err) { 146 | cb(null, data) 147 | } else if (options.username && options.password) { 148 | yggdrasilClient.auth({ 149 | user: options.username, 150 | pass: options.password, 151 | token: clientToken, 152 | requestUser: true 153 | }, cb) 154 | } else { 155 | cb(err, data) 156 | } 157 | }) 158 | } 159 | }) 160 | } else { 161 | // trust that the provided session is a working one 162 | cb(null, options.session) 163 | } 164 | } else { 165 | yggdrasilClient.auth({ 166 | user: options.username, 167 | pass: options.password, 168 | token: clientToken 169 | }, cb) 170 | } 171 | } else { 172 | // assume the server is in offline mode and just go for it. 173 | client.username = options.username 174 | options.connect(client) 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /src/datatypes/compiler-minecraft.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-return-assign */ 2 | const UUID = require('uuid-1345') 3 | const minecraft = require('./minecraft') 4 | 5 | module.exports = { 6 | Read: { 7 | varlong: ['native', minecraft.varlong[0]], 8 | UUID: ['native', (buffer, offset) => { 9 | return { 10 | value: UUID.stringify(buffer.slice(offset, 16 + offset)), 11 | size: 16 12 | } 13 | }], 14 | restBuffer: ['native', (buffer, offset) => { 15 | return { 16 | value: buffer.slice(offset), 17 | size: buffer.length - offset 18 | } 19 | }], 20 | compressedNbt: ['native', minecraft.compressedNbt[0]], 21 | entityMetadataLoop: ['parametrizable', (compiler, { type, endVal }) => { 22 | let code = 'let cursor = offset\n' 23 | code += 'const data = []\n' 24 | code += 'while (true) {\n' 25 | code += ` if (ctx.u8(buffer, cursor).value === ${endVal}) return { value: data, size: cursor + 1 - offset }\n` 26 | code += ' const elem = ' + compiler.callType(type, 'cursor') + '\n' 27 | code += ' data.push(elem.value)\n' 28 | code += ' cursor += elem.size\n' 29 | code += '}' 30 | return compiler.wrapCode(code) 31 | }], 32 | topBitSetTerminatedArray: ['parametrizable', (compiler, { type, endVal }) => { 33 | let code = 'let cursor = offset\n' 34 | code += 'const data = []\n' 35 | code += 'while (true) {\n' 36 | code += ' const item = ctx.u8(buffer, cursor).value\n' 37 | code += ' buffer[cursor] = buffer[cursor] & 127\n' 38 | code += ' const elem = ' + compiler.callType(type, 'cursor') + '\n' 39 | code += ' data.push(elem.value)\n' 40 | code += ' cursor += elem.size\n' 41 | code += ' if ((item & 128) === 0) return { value: data, size: cursor - offset }\n' 42 | code += '}' 43 | return compiler.wrapCode(code) 44 | }], 45 | registryEntryHolder: ['parametrizable', (compiler, opts) => { 46 | return compiler.wrapCode(` 47 | const { value: n, size: nSize } = ${compiler.callType('varint')} 48 | if (n !== 0) { 49 | return { value: { ${opts.baseName}: n - 1 }, size: nSize } 50 | } else { 51 | const holder = ${compiler.callType(opts.otherwise.type, 'offset + nSize')} 52 | return { value: { ${opts.otherwise.name}: holder.value }, size: nSize + holder.size } 53 | } 54 | `.trim()) 55 | }], 56 | registryEntryHolderSet: ['parametrizable', (compiler, opts) => { 57 | return compiler.wrapCode(` 58 | const { value: n, size: nSize } = ${compiler.callType('varint')} 59 | if (n === 0) { 60 | const base = ${compiler.callType(opts.base.type, 'offset + nSize')} 61 | return { value: { ${opts.base.name}: base.value }, size: base.size + nSize } 62 | } else { 63 | const set = [] 64 | let accSize = nSize 65 | for (let i = 0; i < n - 1; i++) { 66 | const entry = ${compiler.callType(opts.otherwise.type, 'offset + accSize')} 67 | set.push(entry.value) 68 | accSize += entry.size 69 | } 70 | return { value: { ${opts.otherwise.name}: set }, size: accSize } 71 | } 72 | `.trim()) 73 | }] 74 | }, 75 | Write: { 76 | varlong: ['native', minecraft.varlong[1]], 77 | UUID: ['native', (value, buffer, offset) => { 78 | const buf = value.length === 32 ? Buffer.from(value, 'hex') : UUID.parse(value) 79 | buf.copy(buffer, offset) 80 | return offset + 16 81 | }], 82 | restBuffer: ['native', (value, buffer, offset) => { 83 | value.copy(buffer, offset) 84 | return offset + value.length 85 | }], 86 | compressedNbt: ['native', minecraft.compressedNbt[1]], 87 | entityMetadataLoop: ['parametrizable', (compiler, { type, endVal }) => { 88 | let code = 'for (const i in value) {\n' 89 | code += ' offset = ' + compiler.callType('value[i]', type) + '\n' 90 | code += '}\n' 91 | code += `return offset + ctx.u8(${endVal}, buffer, offset)` 92 | return compiler.wrapCode(code) 93 | }], 94 | topBitSetTerminatedArray: ['parametrizable', (compiler, { type }) => { 95 | let code = 'let prevOffset = offset\n' 96 | code += 'let ind = 0\n' 97 | code += 'for (const i in value) {\n' 98 | code += ' prevOffset = offset\n' 99 | code += ' offset = ' + compiler.callType('value[i]', type) + '\n' 100 | code += ' buffer[prevOffset] = ind !== value.length-1 ? (buffer[prevOffset] | 128) : buffer[prevOffset]\n' 101 | code += ' ind++\n' 102 | code += '}\n' 103 | code += 'return offset' 104 | return compiler.wrapCode(code) 105 | }], 106 | registryEntryHolder: ['parametrizable', (compiler, opts) => { 107 | const baseName = `value.${opts.baseName}` 108 | const otherwiseName = `value.${opts.otherwise.name}` 109 | return compiler.wrapCode(` 110 | if (${baseName} != null) { 111 | offset = ${compiler.callType(`${baseName} + 1`, 'varint')} 112 | } else if (${otherwiseName}) { 113 | offset += 1 114 | offset = ${compiler.callType(`${otherwiseName}`, opts.otherwise.type)} 115 | } else { 116 | throw new Error('registryEntryHolder type requires "${baseName}" or "${otherwiseName}" fields to be set') 117 | } 118 | return offset 119 | `.trim()) 120 | }], 121 | registryEntryHolderSet: ['parametrizable', (compiler, opts) => { 122 | const baseName = `value.${opts.base.name}` 123 | const otherwiseName = `value.${opts.otherwise.name}` 124 | return compiler.wrapCode(` 125 | if (${baseName} != null) { 126 | offset = ${compiler.callType(0, 'varint')} 127 | offset = ${compiler.callType(`${baseName}`, opts.base.type)} 128 | } else if (${otherwiseName}) { 129 | offset = ${compiler.callType(`${otherwiseName}.length + 1`, 'varint')} 130 | for (let i = 0; i < ${otherwiseName}.length; i++) { 131 | offset = ${compiler.callType(`${otherwiseName}[i]`, opts.otherwise.type)} 132 | } 133 | } else { 134 | throw new Error('registryEntryHolder type requires "${opts.base.name}" or "${opts.otherwise.name}" fields to be set') 135 | } 136 | return offset 137 | `.trim()) 138 | }] 139 | }, 140 | SizeOf: { 141 | varlong: ['native', minecraft.varlong[2]], 142 | UUID: ['native', 16], 143 | restBuffer: ['native', (value) => { 144 | return value.length 145 | }], 146 | compressedNbt: ['native', minecraft.compressedNbt[2]], 147 | entityMetadataLoop: ['parametrizable', (compiler, { type }) => { 148 | let code = 'let size = 1\n' 149 | code += 'for (const i in value) {\n' 150 | code += ' size += ' + compiler.callType('value[i]', type) + '\n' 151 | code += '}\n' 152 | code += 'return size' 153 | return compiler.wrapCode(code) 154 | }], 155 | topBitSetTerminatedArray: ['parametrizable', (compiler, { type }) => { 156 | let code = 'let size = 0\n' 157 | code += 'for (const i in value) {\n' 158 | code += ' size += ' + compiler.callType('value[i]', type) + '\n' 159 | code += '}\n' 160 | code += 'return size' 161 | return compiler.wrapCode(code) 162 | }], 163 | registryEntryHolder: ['parametrizable', (compiler, opts) => { 164 | const baseName = `value.${opts.baseName}` 165 | const otherwiseName = `value.${opts.otherwise.name}` 166 | return compiler.wrapCode(` 167 | let size = 0 168 | if (${baseName} != null) { 169 | size += ${compiler.callType(`${baseName} + 1`, 'varint')} 170 | } else if (${otherwiseName}) { 171 | size += 1 172 | size += ${compiler.callType(`${otherwiseName}`, opts.otherwise.type)} 173 | } else { 174 | throw new Error('registryEntryHolder type requires "${baseName}" or "${otherwiseName}" fields to be set') 175 | } 176 | return size 177 | `.trim()) 178 | }], 179 | registryEntryHolderSet: ['parametrizable', (compiler, opts) => { 180 | const baseName = `value.${opts.base.name}` 181 | const otherwiseName = `value.${opts.otherwise.name}` 182 | return compiler.wrapCode(` 183 | let size = 0 184 | if (${baseName} != null) { 185 | size += ${compiler.callType(0, 'varint')} 186 | size += ${compiler.callType(`${baseName}`, opts.base.type)} 187 | } else if (${otherwiseName}) { 188 | size += ${compiler.callType(`${otherwiseName}.length + 1`, 'varint')} 189 | for (let i = 0; i < ${otherwiseName}.length; i++) { 190 | size += ${compiler.callType(`${otherwiseName}[i]`, opts.otherwise.type)} 191 | } 192 | } else { 193 | throw new Error('registryEntryHolder type requires "${opts.base.name}" or "${opts.otherwise.name}" fields to be set') 194 | } 195 | return size 196 | `.trim()) 197 | }] 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # minecraft protocol 2 | [![NPM version](https://img.shields.io/npm/v/minecraft-protocol.svg)](https://www.npmjs.com/package/minecraft-protocol) 3 | [![Build Status](https://github.com/PrismarineJS/node-minecraft-protocol/workflows/CI/badge.svg)](https://github.com/PrismarineJS/node-minecraft-protocol/actions?query=workflow%3A%22CI%22) 4 | [![Discord](https://img.shields.io/badge/chat-on%20discord-brightgreen.svg)](https://discord.gg/GsEFRM8) 5 | [![Gitter](https://img.shields.io/badge/chat-on%20gitter-brightgreen.svg)](https://gitter.im/PrismarineJS/general) 6 | [![Irc](https://img.shields.io/badge/chat-on%20irc-brightgreen.svg)](https://irc.gitter.im/) 7 | 8 | [![Try it on gitpod](https://img.shields.io/badge/try-on%20gitpod-brightgreen.svg)](https://gitpod.io/#https://github.com/PrismarineJS/node-minecraft-protocol) 9 | 10 | Parse and serialize minecraft packets, plus authentication and encryption. 11 | 12 | ## Features 13 | 14 | * Supports Minecraft PC version 15 | 1.7.10, 1.8.8, 1.9 (15w40b, 1.9, 1.9.1-pre2, 1.9.2, 1.9.4), 1.10 (16w20a, 1.10-pre1, 1.10, 1.10.1, 1.10.2), 16 | 1.11 (16w35a, 1.11, 1.11.2), 1.12 (17w15a, 17w18b, 1.12-pre4, 1.12, 1.12.1, 1.12.2), 17 | 1.13 (17w50a, 1.13, 1.13.1, 1.13.2-pre1, 1.13.2-pre2, 1.13.2),1.14 (1.14, 1.14.1, 1.14.3, 1.14.4), 18 | 1.15 (1.15, 1.15.1, 1.15.2), 1.16 (20w13b, 20w14a, 1.16-rc1, 1.16, 1.16.1, 1.16.2, 1.16.3, 1.16.4, 1.16.5), 19 | 1.17 (21w07a, 1.17, 1.17.1), 1.18 (1.18, 1.18.1 and 1.18.2), 20 | 1.19 (1.19, 1.19.1, 1.19.2, 1.19.3, 1.19.4), 1.20 (1.20, 1.20.1, 1.20.2, 1.20.3, 1.20.4, 1.20.5, 1.20.6), 21 | 1.21, 1.21.1, 1.21.3, 1.21.4, 1.21.5, 1.21.6, 1.21.8 22 | 23 | 24 | * Parses all packets and emits events with packet fields as JavaScript 25 | objects. 26 | * Send a packet by supplying fields as a JavaScript object. 27 | * Client 28 | - Authenticating and logging in 29 | - Encryption 30 | - Compression 31 | - Both online and offline mode 32 | - Respond to keep-alive packets 33 | - Follow DNS service records (SRV) 34 | - Ping a server for status 35 | * Server 36 | - Online/Offline mode 37 | - Encryption 38 | - Compression 39 | - Handshake 40 | - Keep-alive checking 41 | - Ping status 42 | * Robust test coverage. 43 | * Optimized for rapidly staying up to date with Minecraft protocol updates. 44 | 45 | Want to contribute on something important for PrismarineJS ? go to https://github.com/PrismarineJS/mineflayer/wiki/Big-Prismarine-projects 46 | 47 | ## Third Party Plugins 48 | 49 | node-minecraft-protocol is pluggable. 50 | 51 | * [minecraft-protocol-forge](https://github.com/PrismarineJS/node-minecraft-protocol-forge) add forge support to minecraft-protocol 52 | 53 | ## Projects Using node-minecraft-protocol 54 | 55 | * [mineflayer](https://github.com/PrismarineJS/mineflayer/) - Create minecraft 56 | bots with a stable, high level API. 57 | * [mcserve](https://github.com/andrewrk/mcserve) - Runs and monitors your 58 | minecraft server, provides real-time web interface, allow your users to 59 | create bots. 60 | * [flying-squid](https://github.com/PrismarineJS/flying-squid) - Create minecraft 61 | servers with a high level API, also a minecraft server by itself. 62 | * [pakkit](https://github.com/Heath123/pakkit) - A GUI tool to monitor Minecraft packets in real time, allowing you to view their data and interactively edit and resend them. 63 | * [minecraft-packet-debugger](https://github.com/wvffle/minecraft-packet-debugger) - A tool to capture Minecraft packets in a buffer then view them in a browser. 64 | * [aresrpg](https://github.com/aresrpg/aresrpg) - An open-source mmorpg minecraft server. 65 | * [SteveProxy](https://github.com/SteveProxy/proxy) - Proxy for Minecraft with the ability to change the gameplay using plugins. 66 | * and [several thousands others](https://github.com/PrismarineJS/node-minecraft-protocol/network/dependents?package_id=UGFja2FnZS0xODEzMDk0OQ%3D%3D) 67 | 68 | ## Installation 69 | 70 | `npm install minecraft-protocol` 71 | 72 | ## Documentation 73 | 74 | * [API doc](API.md) 75 | * [faq](FAQ.md) 76 | * [protocol doc](https://prismarinejs.github.io/minecraft-data/?d=protocol) and [wiki.vg/Protocol](https://wiki.vg/Protocol) 77 | 78 | ## Usage 79 | 80 | ### Echo client example 81 | 82 | ```js 83 | const mc = require('minecraft-protocol'); 84 | const client = mc.createClient({ 85 | host: "localhost", // optional 86 | port: 25565, // set if you need a port that isn't 25565 87 | username: 'Bot', // username to join as if auth is `offline`, else a unique identifier for this account. Switch if you want to change accounts 88 | // version: false, // only set if you need a specific version or snapshot (ie: "1.8.9" or "1.16.5"), otherwise it's set automatically 89 | // password: '12345678' // set if you want to use password-based auth (may be unreliable). If specified, the `username` must be an email 90 | }); 91 | 92 | client.on('playerChat', function (ev) { 93 | // Listen for chat messages and echo them back. 94 | const content = ev.formattedMessage 95 | ? JSON.parse(ev.formattedMessage) 96 | : ev.unsignedChat 97 | ? JSON.parse(ev.unsignedContent) 98 | : ev.plainMessage 99 | const jsonMsg = JSON.parse(packet.message) 100 | if (ev.senderName === client.username) return 101 | client.chat(JSON.stringify(content)) 102 | }); 103 | ``` 104 | 105 | Set `auth` to `offline` if the server is in offline mode. If `auth` is set to `microsoft`, you will be prompted to login to microsoft.com with a code in your browser. After signing in on your browser, the client will automatically obtain and cache authentication tokens (under your specified username) so you don't have to sign-in again. 106 | 107 | To switch the account, update the supplied username. By default, cached tokens will be stored in your user's .minecraft folder, or if profilesFolder is specified, they'll instead be stored there. For more information on bot options see the [API doc](./API.md). 108 | 109 | Note: SRV records will only be looked up if the port is unspecified or set to 25565 and if the `host` is a valid non-local domain name. 110 | 111 | ### Client example joining a Realm 112 | 113 | Example to connect to a Realm that the authenticating account is owner of or has been invited to: 114 | 115 | ```js 116 | const mc = require('minecraft-protocol'); 117 | const client = mc.createClient({ 118 | realms: { 119 | pickRealm: (realms) => realms[0] // Function which recieves an array of joined/owned Realms and must return a single Realm. Can be async 120 | }, 121 | auth: 'microsoft' 122 | }) 123 | ``` 124 | 125 | ### Hello World server example 126 | 127 | For a more up to date example, see examples/server/server.js. 128 | 129 | ```js 130 | const mc = require('minecraft-protocol') 131 | const nbt = require('prismarine-nbt') 132 | const server = mc.createServer({ 133 | 'online-mode': true, // optional 134 | encryption: true, // optional 135 | host: '0.0.0.0', // optional 136 | port: 25565, // optional 137 | version: '1.18' 138 | }) 139 | const mcData = require('minecraft-data')(server.version) 140 | 141 | function chatText (text) { 142 | return mcData.supportFeature('chatPacketsUseNbtComponents') 143 | ? nbt.comp({ text: nbt.string(text) }) 144 | : JSON.stringify({ text }) 145 | } 146 | 147 | server.on('playerJoin', function(client) { 148 | const loginPacket = mcData.loginPacket 149 | 150 | client.write('login', { 151 | ...loginPacket, 152 | enforceSecureChat: false, 153 | entityId: client.id, 154 | hashedSeed: [0, 0], 155 | maxPlayers: server.maxPlayers, 156 | viewDistance: 10, 157 | reducedDebugInfo: false, 158 | enableRespawnScreen: true, 159 | isDebug: false, 160 | isFlat: false 161 | }) 162 | 163 | client.write('position', { 164 | x: 0, 165 | y: 255, 166 | z: 0, 167 | yaw: 0, 168 | pitch: 0, 169 | flags: 0x00 170 | }) 171 | 172 | const message = { 173 | translate: 'chat.type.announcement', 174 | with: [ 175 | 'Server', 176 | 'Hello, world!' 177 | ] 178 | } 179 | if (mcData.supportFeature('signedChat')) { 180 | client.write('player_chat', { 181 | plainMessage: message, 182 | signedChatContent: '', 183 | unsignedChatContent: chatText(message), 184 | type: mcData.supportFeature('chatTypeIsHolder') ? { chatType: 1 } : 0, 185 | senderUuid: 'd3527a0b-bc03-45d5-a878-2aafdd8c8a43', // random 186 | senderName: JSON.stringify({ text: 'me' }), 187 | senderTeam: undefined, 188 | timestamp: Date.now(), 189 | salt: 0n, 190 | signature: mcData.supportFeature('useChatSessions') ? undefined : Buffer.alloc(0), 191 | previousMessages: [], 192 | filterType: 0, 193 | networkName: JSON.stringify({ text: 'me' }) 194 | }) 195 | } else { 196 | client.write('chat', { message: JSON.stringify({ text: message }), position: 0, sender: 'me' }) 197 | } 198 | }) 199 | ``` 200 | 201 | ## Testing 202 | 203 | * Ensure your system has the `java` executable in `PATH`. 204 | * `MC_SERVER_JAR_DIR=some/path/to/store/minecraft/server/ MC_USERNAME=email@example.com MC_PASSWORD=password npm test` 205 | 206 | ## Debugging 207 | 208 | You can enable some protocol debugging output using `DEBUG` environment variable: 209 | 210 | ```bash 211 | DEBUG="minecraft-protocol" node [...] 212 | ``` 213 | 214 | On Windows: 215 | ``` 216 | set DEBUG=minecraft-protocol 217 | node your_script.js 218 | ``` 219 | 220 | ## Contribute 221 | 222 | Please read https://github.com/PrismarineJS/prismarine-contribute 223 | 224 | ## History 225 | 226 | See [history](HISTORY.md) 227 | 228 | ## Related 229 | 230 | * [node-rcon](https://github.com/pushrax/node-rcon) can be used to access the rcon server in the minecraft server 231 | * [map-colors][aresmapcolor] can be used to convert any image into a buffer of minecraft compatible colors 232 | 233 | [aresmapcolor]: https://github.com/AresRPG/aresrpg-map-colors 234 | -------------------------------------------------------------------------------- /src/client.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const EventEmitter = require('events').EventEmitter 3 | const compression = require('./transforms/compression') 4 | const framing = require('./transforms/framing') 5 | const states = require('./states') 6 | const debug = require('debug')('minecraft-protocol') 7 | const debugSkip = process.env.DEBUG_SKIP?.split(',') ?? [] 8 | 9 | const createSerializer = require('./transforms/serializer').createSerializer 10 | const createDeserializer = require('./transforms/serializer').createDeserializer 11 | const createCipher = require('./transforms/encryption').createCipher 12 | const createDecipher = require('./transforms/encryption').createDecipher 13 | 14 | const closeTimeout = 30 * 1000 15 | 16 | class Client extends EventEmitter { 17 | constructor (isServer, version, customPackets, hideErrors = false) { 18 | super() 19 | this.customPackets = customPackets 20 | this.version = version 21 | this.isServer = !!isServer 22 | this.splitter = framing.createSplitter() 23 | this.packetsToParse = {} 24 | this.compressor = null 25 | this.framer = framing.createFramer() 26 | this.cipher = null 27 | this.decipher = null 28 | this.decompressor = null 29 | this.ended = true 30 | this.latency = 0 31 | this.hideErrors = hideErrors 32 | this.closeTimer = null 33 | const mcData = require('minecraft-data')(version) 34 | this._supportFeature = mcData.supportFeature 35 | this.state = states.HANDSHAKING 36 | this._hasBundlePacket = mcData.supportFeature('hasBundlePacket') 37 | } 38 | 39 | get state () { 40 | return this.protocolState 41 | } 42 | 43 | setSerializer (state) { 44 | this.serializer = createSerializer({ isServer: this.isServer, version: this.version, state, customPackets: this.customPackets }) 45 | this.deserializer = createDeserializer({ 46 | isServer: this.isServer, 47 | version: this.version, 48 | state, 49 | packetsToParse: 50 | this.packetsToParse, 51 | customPackets: this.customPackets, 52 | noErrorLogging: this.hideErrors 53 | }) 54 | 55 | this.splitter.recognizeLegacyPing = state === states.HANDSHAKING 56 | 57 | this.serializer.on('error', (e) => { 58 | let parts 59 | if (e.field) { 60 | parts = e.field.split('.') 61 | parts.shift() 62 | } else { parts = [] } 63 | const serializerDirection = !this.isServer ? 'toServer' : 'toClient' 64 | e.field = [this.protocolState, serializerDirection].concat(parts).join('.') 65 | e.message = `Serialization error for ${e.field} : ${e.message}` 66 | if (!this.compressor) { this.serializer.pipe(this.framer) } else { this.serializer.pipe(this.compressor) } 67 | this.emit('error', e) 68 | }) 69 | 70 | this.deserializer.on('error', (e) => { 71 | let parts = [] 72 | if (e.field) { 73 | parts = e.field.split('.') 74 | parts.shift() 75 | } 76 | const deserializerDirection = this.isServer ? 'toServer' : 'toClient' 77 | e.field = [this.protocolState, deserializerDirection].concat(parts).join('.') 78 | e.message = e.buffer ? `Parse error for ${e.field} (${e.buffer?.length} bytes, ${e.buffer?.toString('hex').slice(0, 6)}...) : ${e.message}` : `Parse error for ${e.field}: ${e.message}` 79 | if (!this.compressor) { this.splitter.pipe(this.deserializer) } else { this.decompressor.pipe(this.deserializer) } 80 | this.emit('error', e) 81 | }) 82 | this._mcBundle = [] 83 | const emitPacket = (parsed) => { 84 | this.emit('packet', parsed.data, parsed.metadata, parsed.buffer, parsed.fullBuffer) 85 | this.emit(parsed.metadata.name, parsed.data, parsed.metadata) 86 | this.emit('raw.' + parsed.metadata.name, parsed.buffer, parsed.metadata) 87 | this.emit('raw', parsed.buffer, parsed.metadata) 88 | } 89 | this.deserializer.on('data', (parsed) => { 90 | parsed.metadata.name = parsed.data.name 91 | parsed.data = parsed.data.params 92 | parsed.metadata.state = state 93 | if (debug.enabled && !debugSkip.includes(parsed.metadata.name)) { 94 | debug('read packet ' + state + '.' + parsed.metadata.name) 95 | const s = JSON.stringify(parsed.data, null, 2) 96 | debug(s && s.length > 10000 ? parsed.data : s) 97 | } 98 | if (this._hasBundlePacket && parsed.metadata.name === 'bundle_delimiter') { 99 | if (this._mcBundle.length) { // End bundle 100 | this._mcBundle.forEach(emitPacket) 101 | emitPacket(parsed) 102 | this._mcBundle = [] 103 | } else { // Start bundle 104 | this._mcBundle.push(parsed) 105 | } 106 | } else if (this._mcBundle.length) { 107 | this._mcBundle.push(parsed) 108 | if (this._mcBundle.length > 32) { 109 | this._mcBundle.forEach(emitPacket) 110 | this._mcBundle = [] 111 | this._hasBundlePacket = false 112 | } 113 | } else { 114 | emitPacket(parsed) 115 | } 116 | }) 117 | } 118 | 119 | set state (newProperty) { 120 | const oldProperty = this.protocolState 121 | this.protocolState = newProperty 122 | 123 | if (this.serializer) { 124 | if (!this.compressor) { 125 | this.serializer.unpipe() 126 | this.splitter.unpipe(this.deserializer) 127 | } else { 128 | this.serializer.unpipe(this.compressor) 129 | this.decompressor.unpipe(this.deserializer) 130 | } 131 | 132 | this.serializer.removeAllListeners() 133 | this.deserializer.removeAllListeners() 134 | } 135 | this.setSerializer(this.protocolState) 136 | 137 | if (!this.compressor) { 138 | this.serializer.pipe(this.framer) 139 | this.splitter.pipe(this.deserializer) 140 | } else { 141 | this.serializer.pipe(this.compressor) 142 | if (globalThis.debugNMP) this.decompressor.on('data', (data) => { console.log('DES>', data.toString('hex')) }) 143 | this.decompressor.pipe(this.deserializer) 144 | } 145 | 146 | this.emit('state', newProperty, oldProperty) 147 | } 148 | 149 | get compressionThreshold () { 150 | return this.compressor == null ? -2 : this.compressor.compressionThreshold 151 | } 152 | 153 | set compressionThreshold (threshold) { 154 | this.setCompressionThreshold(threshold) 155 | } 156 | 157 | setSocket (socket) { 158 | this.ended = false 159 | 160 | // TODO : A lot of other things needs to be done. 161 | const endSocket = () => { 162 | if (this.ended) return 163 | this.ended = true 164 | clearTimeout(this.closeTimer) 165 | this.socket.removeListener('close', endSocket) 166 | this.socket.removeListener('end', endSocket) 167 | this.socket.removeListener('timeout', endSocket) 168 | this.emit('end', this._endReason || 'socketClosed') 169 | } 170 | 171 | const onFatalError = (err) => { 172 | this.emit('error', err) 173 | endSocket() 174 | } 175 | 176 | const onError = (err) => this.emit('error', err) 177 | 178 | this.socket = socket 179 | 180 | if (this.socket.setNoDelay) { this.socket.setNoDelay(true) } 181 | 182 | this.socket.on('connect', () => this.emit('connect')) 183 | 184 | this.socket.on('error', onFatalError) 185 | this.socket.on('close', endSocket) 186 | this.socket.on('end', endSocket) 187 | this.socket.on('timeout', endSocket) 188 | this.framer.on('error', onError) 189 | this.splitter.on('error', onError) 190 | 191 | this.socket.pipe(this.splitter) 192 | this.framer.pipe(this.socket) 193 | } 194 | 195 | end (reason) { 196 | this._endReason = reason 197 | /* ending the serializer will end the whole chain 198 | serializer -> framer -> socket -> splitter -> deserializer */ 199 | if (this.serializer) { 200 | this.serializer.end() 201 | } else { 202 | if (this.socket) this.socket.end() 203 | } 204 | if (this.socket) { 205 | this.closeTimer = setTimeout( 206 | this.socket.destroy.bind(this.socket), 207 | closeTimeout 208 | ) 209 | } 210 | } 211 | 212 | setEncryption (sharedSecret) { 213 | if (this.cipher != null) { this.emit('error', new Error('Set encryption twice!')) } 214 | this.cipher = createCipher(sharedSecret) 215 | this.cipher.on('error', (err) => this.emit('error', err)) 216 | this.framer.unpipe(this.socket) 217 | this.framer.pipe(this.cipher).pipe(this.socket) 218 | this.decipher = createDecipher(sharedSecret) 219 | this.decipher.on('error', (err) => this.emit('error', err)) 220 | this.socket.unpipe(this.splitter) 221 | this.socket.pipe(this.decipher).pipe(this.splitter) 222 | } 223 | 224 | setCompressionThreshold (threshold) { 225 | if (this.compressor == null) { 226 | this.compressor = compression.createCompressor(threshold) 227 | this.compressor.on('error', (err) => this.emit('error', err)) 228 | this.serializer.unpipe(this.framer) 229 | this.serializer.pipe(this.compressor).pipe(this.framer) 230 | this.decompressor = compression.createDecompressor(threshold, this.hideErrors) 231 | this.decompressor.on('error', (err) => this.emit('error', err)) 232 | this.splitter.unpipe(this.deserializer) 233 | this.splitter.pipe(this.decompressor).pipe(this.deserializer) 234 | } else { 235 | this.decompressor.threshold = threshold 236 | this.compressor.threshold = threshold 237 | } 238 | } 239 | 240 | write (name, params) { 241 | if (!this.serializer.writable) { return } 242 | if (debug.enabled && !debugSkip.includes(name)) { 243 | debug('writing packet ' + this.state + '.' + name) 244 | debug(params) 245 | } 246 | this.serializer.write({ name, params }) 247 | } 248 | 249 | writeBundle (packets) { 250 | if (this._hasBundlePacket) this.write('bundle_delimiter', {}) 251 | for (const [name, params] of packets) this.write(name, params) 252 | if (this._hasBundlePacket) this.write('bundle_delimiter', {}) 253 | } 254 | 255 | writeRaw (buffer) { 256 | const stream = this.compressor === null ? this.framer : this.compressor 257 | if (!stream.writable) { return } 258 | stream.write(buffer) 259 | } 260 | 261 | // TCP/IP-specific (not generic Stream) method for backwards-compatibility 262 | connect (port, host) { 263 | const options = { port, host } 264 | if (!this.options) this.options = options 265 | require('./client/tcp_dns')(this, options) 266 | options.connect(this) 267 | } 268 | } 269 | 270 | module.exports = Client 271 | -------------------------------------------------------------------------------- /src/server/login.js: -------------------------------------------------------------------------------- 1 | const uuid = require('../datatypes/uuid') 2 | const crypto = require('crypto') 3 | const pluginChannels = require('../client/pluginChannels') 4 | const states = require('../states') 5 | const yggdrasil = require('yggdrasil') 6 | const chatPlugin = require('./chat') 7 | const { concat } = require('../transforms/binaryStream') 8 | const { mojangPublicKeyPem } = require('./constants') 9 | const debug = require('debug')('minecraft-protocol') 10 | const NodeRSA = require('node-rsa') 11 | const nbt = require('prismarine-nbt') 12 | 13 | /** 14 | * @param {import('../index').Client} client 15 | * @param {import('../index').Server} server 16 | * @param {Object} options 17 | */ 18 | module.exports = function (client, server, options) { 19 | const mojangPubKey = crypto.createPublicKey(mojangPublicKeyPem) 20 | const raise = (translatableError) => client.end(translatableError, JSON.stringify({ translate: translatableError })) 21 | const yggdrasilServer = yggdrasil.server({ agent: options.agent }) 22 | const { 23 | 'online-mode': onlineMode = true, 24 | kickTimeout = 30 * 1000, 25 | errorHandler: clientErrorHandler = function (client, err) { 26 | if (!options.hideErrors) console.debug('Disconnecting client because error', err) 27 | client.end(err) 28 | } 29 | } = options 30 | 31 | let serverId 32 | 33 | client.on('error', function (err) { 34 | clientErrorHandler(client, err) 35 | }) 36 | client.on('end', () => { 37 | clearTimeout(loginKickTimer) 38 | }) 39 | client.once('login_start', onLogin) 40 | 41 | function kickForNotLoggingIn () { 42 | client.end('LoginTimeout') 43 | } 44 | let loginKickTimer = setTimeout(kickForNotLoggingIn, kickTimeout) 45 | 46 | function onLogin (packet) { 47 | const mcData = require('minecraft-data')(client.version) 48 | client.supportFeature = mcData.supportFeature 49 | 50 | client.username = packet.username 51 | const isException = !!server.onlineModeExceptions[client.username.toLowerCase()] 52 | const needToVerify = (onlineMode && !isException) || (!onlineMode && isException) 53 | 54 | if (mcData.supportFeature('signatureEncryption')) { 55 | if (options.enforceSecureProfile && !packet.signature) { 56 | raise('multiplayer.disconnect.missing_public_key') 57 | return 58 | } 59 | } 60 | 61 | if (packet.signature) { 62 | if (packet.signature.timestamp < BigInt(Date.now())) { 63 | debug('Client sent expired tokens') 64 | raise('multiplayer.disconnect.invalid_public_key_signature') 65 | return // expired tokens, client needs to restart game 66 | } 67 | 68 | try { 69 | const publicKey = crypto.createPublicKey({ key: packet.signature.publicKey, format: 'der', type: 'spki' }) 70 | const signable = mcData.supportFeature('profileKeySignatureV2') 71 | ? concat('UUID', packet.playerUUID, 'i64', packet.signature.timestamp, 'buffer', publicKey.export({ type: 'spki', format: 'der' })) 72 | : Buffer.from(packet.signature.timestamp + mcPubKeyToPem(packet.signature.publicKey), 'utf8') // (expires at + publicKey) 73 | 74 | // This makes sure 'signable' when signed with the mojang private key equals signature in this packet 75 | if (!crypto.verify('RSA-SHA1', signable, mojangPubKey, packet.signature.signature)) { 76 | debug('Signature mismatch') 77 | raise('multiplayer.disconnect.invalid_public_key_signature') 78 | return 79 | } 80 | client.profileKeys = { public: publicKey } 81 | } catch (err) { 82 | debug(err) 83 | raise('multiplayer.disconnect.invalid_public_key') 84 | return 85 | } 86 | } 87 | 88 | if (needToVerify) { 89 | serverId = crypto.randomBytes(4).toString('hex') 90 | client.verifyToken = crypto.randomBytes(4) 91 | const publicKeyStrArr = server.serverKey.exportKey('pkcs8-public-pem').split('\n') 92 | let publicKeyStr = '' 93 | for (let i = 1; i < publicKeyStrArr.length - 1; i++) { 94 | publicKeyStr += publicKeyStrArr[i] 95 | } 96 | client.publicKey = Buffer.from(publicKeyStr, 'base64') 97 | client.once('encryption_begin', onEncryptionKeyResponse) 98 | client.write('encryption_begin', { 99 | serverId, 100 | publicKey: client.publicKey, 101 | verifyToken: client.verifyToken, 102 | shouldAuthenticate: true 103 | }) 104 | } else { 105 | loginClient() 106 | } 107 | } 108 | 109 | function onEncryptionKeyResponse (packet) { 110 | if (client.profileKeys) { 111 | if (options.enforceSecureProfile && packet.hasVerifyToken) { 112 | raise('multiplayer.disconnect.missing_public_key') 113 | return // Unexpected - client has profile keys, and we expect secure profile 114 | } 115 | } 116 | 117 | const keyRsa = new NodeRSA(server.serverKey.exportKey('pkcs1'), 'private', { encryptionScheme: 'pkcs1' }) 118 | keyRsa.setOptions({ environment: 'browser' }) 119 | 120 | if (packet.hasVerifyToken === false) { 121 | // 1.19, hasVerifyToken is set and equal to false IF chat signing is enabled 122 | // This is the default action starting in 1.19.1. 123 | const signable = concat('buffer', client.verifyToken, 'i64', packet.crypto.salt) 124 | if (!crypto.verify('sha256WithRSAEncryption', signable, client.profileKeys.public, packet.crypto.messageSignature)) { 125 | raise('multiplayer.disconnect.invalid_public_key_signature') 126 | return 127 | } 128 | } else { 129 | const encryptedToken = packet.hasVerifyToken ? packet.crypto.verifyToken : packet.verifyToken 130 | try { 131 | const decryptedToken = keyRsa.decrypt(encryptedToken) 132 | 133 | if (!client.verifyToken.equals(decryptedToken)) { 134 | client.end('DidNotEncryptVerifyTokenProperly') 135 | return 136 | } 137 | } catch { 138 | client.end('DidNotEncryptVerifyTokenProperly') 139 | return 140 | } 141 | } 142 | let sharedSecret 143 | try { 144 | sharedSecret = keyRsa.decrypt(packet.sharedSecret) 145 | } catch (e) { 146 | client.end('DidNotEncryptVerifyTokenProperly') 147 | return 148 | } 149 | 150 | client.setEncryption(sharedSecret) 151 | 152 | const isException = !!server.onlineModeExceptions[client.username.toLowerCase()] 153 | const needToVerify = (onlineMode && !isException) || (!onlineMode && isException) 154 | const nextStep = needToVerify ? verifyUsername : loginClient 155 | nextStep() 156 | 157 | function verifyUsername () { 158 | yggdrasilServer.hasJoined(client.username, serverId, sharedSecret, client.publicKey, function (err, profile) { 159 | if (err) { 160 | client.end('Failed to verify username!') 161 | return 162 | } 163 | // Convert to a valid UUID until the session server updates and does 164 | // it automatically 165 | client.uuid = profile.id.replace(/(\w{8})(\w{4})(\w{4})(\w{4})(\w{12})/, '$1-$2-$3-$4-$5') 166 | client.username = profile.name 167 | client.profile = profile 168 | loginClient() 169 | }) 170 | } 171 | } 172 | 173 | function loginClient () { 174 | const isException = !!server.onlineModeExceptions[client.username.toLowerCase()] 175 | if (onlineMode === false || isException) { 176 | client.uuid = uuid.nameToMcOfflineUUID(client.username) 177 | } 178 | options.beforeLogin?.(client) 179 | if (client.protocolVersion >= 27) { // 14w28a (27) added whole-protocol compression (http://wiki.vg/Protocol_History#14w28a), earlier versions per-packet compressed TODO: refactor into minecraft-data 180 | client.write('compress', { threshold: 256 }) // Default threshold is 256 181 | client.compressionThreshold = 256 182 | } 183 | // TODO: find out what properties are on 'success' packet 184 | client.write('success', { 185 | uuid: client.uuid, 186 | username: client.username, 187 | properties: [] 188 | }) 189 | if (client.supportFeature('hasConfigurationState')) { 190 | client.once('login_acknowledged', onClientLoginAck) 191 | } else { 192 | client.state = states.PLAY 193 | } 194 | client.settings = {} 195 | 196 | if (client.supportFeature('chainedChatWithHashing')) { // 1.19.1+ 197 | const jsonMotd = JSON.stringify(server.motdMsg ?? { text: server.motd }) 198 | const nbtMotd = nbt.comp({ text: nbt.string(server.motd) }) 199 | client.write('server_data', { 200 | motd: client.supportFeature('chatPacketsUseNbtComponents') ? nbtMotd : jsonMotd, 201 | icon: server.favicon, // b64 202 | iconBytes: server.favicon ? Buffer.from(server.favicon, 'base64') : undefined, 203 | previewsChat: options.enableChatPreview, 204 | // Note: in 1.20.5+ user must send this with `login` 205 | enforcesSecureChat: options.enforceSecureProfile 206 | }) 207 | } 208 | 209 | clearTimeout(loginKickTimer) 210 | loginKickTimer = null 211 | 212 | server.playerCount += 1 213 | client.once('end', function () { 214 | server.playerCount -= 1 215 | }) 216 | pluginChannels(client, options) 217 | if (client.supportFeature('signedChat')) chatPlugin(client, server, options) 218 | server.emit('login', client) 219 | if (!client.supportFeature('hasConfigurationState')) { 220 | server.emit('playerJoin', client) 221 | } 222 | } 223 | 224 | function onClientLoginAck () { 225 | client.state = states.CONFIGURATION 226 | if (client.supportFeature('segmentedRegistryCodecData')) { 227 | for (const key in options.registryCodec) { 228 | const entry = options.registryCodec[key] 229 | client.write('registry_data', entry) 230 | } 231 | } else { 232 | client.write('registry_data', { codec: options.registryCodec || {} }) 233 | } 234 | client.once('finish_configuration', () => { 235 | client.state = states.PLAY 236 | server.emit('playerJoin', client) 237 | }) 238 | client.write('finish_configuration', {}) 239 | } 240 | } 241 | 242 | function mcPubKeyToPem (mcPubKeyBuffer) { 243 | let pem = '-----BEGIN RSA PUBLIC KEY-----\n' 244 | let base64PubKey = mcPubKeyBuffer.toString('base64') 245 | const maxLineLength = 76 246 | while (base64PubKey.length > 0) { 247 | pem += base64PubKey.substring(0, maxLineLength) + '\n' 248 | base64PubKey = base64PubKey.substring(maxLineLength) 249 | } 250 | pem += '-----END RSA PUBLIC KEY-----\n' 251 | return pem 252 | } 253 | -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { EventEmitter } from 'events'; 4 | import { Socket } from 'net' 5 | import * as Stream from 'stream' 6 | import { Agent } from 'http' 7 | import { Transform } from "readable-stream"; 8 | import { BinaryLike, KeyObject } from 'crypto'; 9 | import { Realm } from "prismarine-realms" 10 | import NodeRSA from 'node-rsa'; 11 | 12 | type PromiseLike = Promise | void 13 | 14 | declare module 'minecraft-protocol' { 15 | export class Client extends EventEmitter { 16 | constructor(isServer: boolean, version: string, customPackets?: any) 17 | state: States 18 | isServer: boolean 19 | socket: Socket 20 | uuid: string 21 | username: string 22 | session?: SessionOption 23 | profile?: any 24 | deserializer: FullPacketParser 25 | serializer: Serializer 26 | latency: number 27 | customPackets: any 28 | protocolVersion: number 29 | version: string 30 | write(name: string, params: any): void 31 | writeRaw(buffer: any): void 32 | compressionThreshold: string 33 | ended: boolean 34 | connect(port: number, host: string): void 35 | setSocket(socket: Socket): void 36 | end(reason?: string): void 37 | registerChannel(name: string, typeDefinition: any, custom?: boolean): void 38 | unregisterChannel(name: string): void 39 | writeChannel(channel: any, params: any): void 40 | signMessage(message: string, timestamp: BigInt, salt?: number, preview?: string, acknowledgements?: Buffer[]): Buffer 41 | verifyMessage(publicKey: Buffer | KeyObject, packet: object): boolean 42 | reportPlayer(uuid: string, reason: 'FALSE_REPORTING' | 'HATE_SPEECH' | 'TERRORISM_OR_VIOLENT_EXTREMISM' | 'CHILD_SEXUAL_EXPLOITATION_OR_ABUSE' | 'IMMINENT_HARM' | 'NON_CONSENSUAL_INTIMATE_IMAGERY' | 'HARASSMENT_OR_BULLYING' | 'DEFAMATION_IMPERSONATION_FALSE_INFORMATION' | 'SELF_HARM_OR_SUICIDE' | 'ALCOHOL_TOBACCO_DRUGS', signatures: Buffer[], comment?: string): Promise 43 | chat(message: string, options?: { timestamp?: BigInt, salt?: BigInt, preview?: BinaryLike, didPreview?: boolean }): void 44 | on(event: 'error', listener: (error: Error) => PromiseLike): this 45 | on(event: 'packet', handler: (data: any, packetMeta: PacketMeta, buffer: Buffer, fullBuffer: Buffer) => PromiseLike): this 46 | on(event: 'raw', handler: (buffer: Buffer, packetMeta: PacketMeta) => PromiseLike): this 47 | on(event: 'session', handler: (session: SessionObject) => PromiseLike): this 48 | on(event: 'state', handler: (newState: States, oldState: States) => PromiseLike): this 49 | on(event: 'end', handler: (reason: string) => PromiseLike): this 50 | on(event: 'connect', handler: () => PromiseLike): this 51 | on(event: string, handler: (data: any, packetMeta: PacketMeta) => PromiseLike): this 52 | on(event: `raw.${string}`, handler: (buffer: Buffer, packetMeta: PacketMeta) => PromiseLike): this 53 | on(event: 'playerChat', handler: (data: { 54 | // (JSON string) The chat message preformatted, if done on server side 55 | formattedMessage: string, 56 | // (Plaintext) The chat message without formatting (for example no ` message` ; instead `message`), on version 1.19+ 57 | plainMessage: string, 58 | // (JSON string) Unsigned formatted chat contents. Should only be present when the message is modified and server has chat previews disabled. Only on versions 1.19.0, 1.19.1 and 1.19.2 59 | unsignedContent?: string, 60 | type: string, 61 | sender: string, 62 | senderName: string, 63 | senderTeam: string, 64 | targetName: string, 65 | verified?: boolean 66 | }) => PromiseLike): this 67 | on(event: 'systemChat', handler: (data: { positionId: number, formattedMessage: string }) => PromiseLike): this 68 | // Emitted after the player enters the PLAY state and can send and recieve game packets 69 | on(event: 'playerJoin', handler: () => void): this 70 | once(event: 'error', listener: (error: Error) => PromiseLike): this 71 | once(event: 'packet', handler: (data: any, packetMeta: PacketMeta, buffer: Buffer, fullBuffer: Buffer) => PromiseLike): this 72 | once(event: 'raw', handler: (buffer: Buffer, packetMeta: PacketMeta) => PromiseLike): this 73 | once(event: 'sessionce', handler: (sessionce: any) => PromiseLike): this 74 | once(event: 'state', handler: (newState: States, oldState: States) => PromiseLike): this 75 | once(event: 'end', handler: (reasonce: string) => PromiseLike): this 76 | once(event: 'connect', handler: () => PromiseLike): this 77 | once(event: string, handler: (data: any, packetMeta: PacketMeta) => PromiseLike): this 78 | once(event: `raw.${string}`, handler: (buffer: Buffer, packetMeta: PacketMeta) => PromiseLike): this 79 | } 80 | 81 | class FullPacketParser extends Transform { 82 | proto: any 83 | mainType: any 84 | noErrorLogging: boolean 85 | constructor (proto: any, mainType: any, noErrorLogging?: boolean) 86 | 87 | parsePacketBuffer(buffer: Buffer): any 88 | } 89 | 90 | class Serializer extends Transform { 91 | proto: any 92 | mainType: any 93 | queue: Buffer 94 | constructor(proto: any, mainType: any) 95 | 96 | createPacketBuffer(packet: any): any 97 | } 98 | 99 | export interface SessionOption { 100 | accessToken: string, 101 | /** My be needed for mojang auth. Is send by mojang on username + password auth */ 102 | clientToken?: string, 103 | selectedProfile: SessionProfile 104 | } 105 | 106 | export interface SessionObject { 107 | accessToken: string, 108 | /** My be needed for mojang auth. Is send by mojang on username + password auth */ 109 | clientToken?: string, 110 | selectedProfile: { 111 | name: string 112 | id: string 113 | } 114 | availableProfiles?: SessionProfile[] 115 | availableProfile?: SessionProfile[] 116 | } 117 | 118 | interface SessionProfile { 119 | /** Character in game name */ 120 | name: string 121 | /** Character UUID in short form */ 122 | id: string 123 | } 124 | 125 | export interface ClientOptions { 126 | username: string 127 | port?: number 128 | auth?: 'mojang' | 'microsoft' | 'offline' | ((client: Client, options: ClientOptions) => void) 129 | password?: string 130 | host?: string 131 | clientToken?: string 132 | accessToken?: string 133 | authServer?: string 134 | authTitle?: string 135 | sessionServer?: string 136 | keepAlive?: boolean 137 | closeTimeout?: number 138 | noPongTimeout?: number 139 | checkTimeoutInterval?: number 140 | version?: string 141 | customPackets?: any 142 | hideErrors?: boolean 143 | skipValidation?: boolean 144 | stream?: Stream 145 | connect?: (client: Client) => void 146 | agent?: Agent 147 | fakeHost?: string 148 | profilesFolder?: string | false 149 | onMsaCode?: (data: MicrosoftDeviceAuthorizationResponse) => void 150 | id?: number 151 | session?: SessionOption 152 | validateChannelProtocol?: boolean, 153 | realms?: RealmsOptions 154 | // 1.19+ 155 | disableChatSigning?: boolean 156 | /** Pass custom client implementation if needed. */ 157 | Client?: Client 158 | } 159 | 160 | export class Server extends EventEmitter { 161 | constructor(version: string, customPackets?: any) 162 | writeToClients(clients: Client[], name: string, params: any): void 163 | onlineModeExceptions: object 164 | clients: { [key: number]: ServerClient } 165 | playerCount: number 166 | maxPlayers: number 167 | motd: string 168 | motdMsg?: Object 169 | favicon: string 170 | serverKey: NodeRSA 171 | close(): void 172 | on(event: 'connection', handler: (client: ServerClient) => PromiseLike): this 173 | on(event: 'error', listener: (error: Error) => PromiseLike): this 174 | on(event: 'login', handler: (client: ServerClient) => PromiseLike): this 175 | on(event: 'listening', listener: () => PromiseLike): this 176 | // Emitted after the player enters the PLAY state and can send and recieve game packets 177 | on(event: 'playerJoin', handler: (client: ServerClient) => void): this 178 | once(event: 'connection', handler: (client: ServerClient) => PromiseLike): this 179 | once(event: 'error', listener: (error: Error) => PromiseLike): this 180 | once(event: 'login', handler: (client: ServerClient) => PromiseLike): this 181 | once(event: 'listening', listener: () => PromiseLike): this 182 | } 183 | 184 | export interface ServerClient extends Client { 185 | id: number 186 | /** You must call this function when the server receives a message from a player and that message gets 187 | broadcast to other players in player_chat packets. This function stores these packets so the server 188 | can then verify a player's lastSeenMessages field in inbound chat packets to ensure chain integrity. */ 189 | logSentMessageFromPeer(packet: object): boolean 190 | } 191 | 192 | export interface ServerOptions { 193 | port?: number 194 | host?: string 195 | kickTimeout?: number 196 | checkTimeoutInterval?: number 197 | 'online-mode'?: boolean 198 | beforePing?: (response: any, client: Client, callback?: (error: any, result: any) => any) => any 199 | beforeLogin?: (client: Client) => void 200 | motd?: string 201 | motdMsg?: Object 202 | maxPlayers?: number 203 | keepAlive?: boolean 204 | version?: string | false 205 | fallbackVersion?: string 206 | favicon?: string 207 | customPackets?: any 208 | errorHandler?: (client: Client, error: Error) => void 209 | hideErrors?: boolean 210 | agent?: Agent 211 | validateChannelProtocol?: boolean 212 | /** (1.19+) Require connecting clients to have chat signing support enabled */ 213 | enforceSecureProfile?: boolean 214 | /** 1.19.1 & 1.19.2 only: If client should send previews of messages they are typing to the server */ 215 | enableChatPreview?: boolean 216 | socketType?: 'tcp' | 'ipc' 217 | Server?: Server 218 | } 219 | 220 | export interface SerializerOptions { 221 | customPackets: any 222 | isServer?: boolean 223 | state?: States 224 | version: string 225 | } 226 | 227 | export interface MicrosoftDeviceAuthorizationResponse { 228 | device_code: string 229 | user_code: string 230 | verification_uri: string 231 | expires_in: number 232 | interval: number 233 | message: string 234 | } 235 | 236 | enum States { 237 | HANDSHAKING = 'handshaking', 238 | LOGIN = 'login', 239 | PLAY = 'play', 240 | CONFIGURATION = 'configuration', 241 | STATUS = 'status', 242 | } 243 | 244 | export interface PacketMeta { 245 | name: string 246 | state: States 247 | } 248 | 249 | export interface PingOptions { 250 | host?: string 251 | majorVersion?: string 252 | port?: number 253 | protocolVersion?: string 254 | version?: string 255 | closeTimeout?: number 256 | noPongTimeout?: number 257 | } 258 | 259 | export interface OldPingResult { 260 | maxPlayers: number, 261 | motd: string 262 | playerCount: number 263 | prefix: string 264 | protocol: number 265 | version: string 266 | } 267 | 268 | export interface NewPingResult { 269 | description: { 270 | text?: string 271 | extra?: any[] 272 | } | string 273 | players: { 274 | max: number 275 | online: number 276 | sample?: { 277 | id: string 278 | name: string 279 | }[] 280 | } 281 | version: { 282 | name: string 283 | protocol: number 284 | } 285 | favicon?: string 286 | latency: number 287 | } 288 | 289 | export interface RealmsOptions { 290 | realmId?: string 291 | pickRealm?: (realms: Realm[]) => Realm 292 | } 293 | 294 | export const states: typeof States 295 | export const supportedVersions: string[] 296 | export const defaultVersion: string 297 | 298 | export function createServer(options: ServerOptions): Server 299 | export function createClient(options: ClientOptions): Client 300 | 301 | // TODO: Create typings on protodef to define here 302 | export function createSerializer({ state, isServer, version, customPackets }: SerializerOptions): any 303 | export function createDeserializer({ state, isServer, version, customPackets }: SerializerOptions): any 304 | 305 | export function ping(options: PingOptions, callback?: (error: Error, result: OldPingResult | NewPingResult) => void): Promise 306 | } 307 | -------------------------------------------------------------------------------- /src/server/chat.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto') 2 | const { computeChatChecksum } = require('../datatypes/checksums') 3 | const concat = require('../transforms/binaryStream').concat 4 | const debug = require('debug')('minecraft-protocol') 5 | const messageExpireTime = 300000 // 5 min (ms) 6 | const { mojangPublicKeyPem } = require('./constants') 7 | 8 | class VerificationError extends Error {} 9 | function validateLastMessages (pending, lastSeen, lastRejected) { 10 | if (lastRejected) { 11 | const rejectedTime = pending.get(lastRejected.sender, lastRejected.signature) 12 | if (rejectedTime) pending.acknowledge(lastRejected.sender, lastRejected.signature) 13 | else throw new VerificationError(`Client rejected a message we never sent from '${lastRejected.sender}'`) 14 | } 15 | 16 | let lastTimestamp 17 | const seenSenders = new Set() 18 | 19 | for (const { messageSender, messageSignature } of lastSeen) { 20 | if (pending.previouslyAcknowledged(messageSender, messageSignature)) continue 21 | 22 | const ts = pending.get(messageSender)(messageSignature) 23 | if (!ts) { 24 | throw new VerificationError(`Client saw a message that we never sent from '${messageSender}'`) 25 | } else if (lastTimestamp && (ts < lastTimestamp)) { 26 | throw new VerificationError(`Received messages out of order: Last acknowledged timestamp was at ${lastTimestamp}, now reading older message at ${ts}`) 27 | } else if (seenSenders.has(messageSender)) { 28 | // in the lastSeen array, last 5 messages from different players are stored, not just last 5 messages 29 | throw new VerificationError(`Two last seen entries from same player not allowed: ${messageSender}`) 30 | } else { 31 | lastTimestamp = ts 32 | seenSenders.add(messageSender) 33 | pending.acknowledgePrior(messageSender, messageSignature) 34 | } 35 | } 36 | 37 | pending.setPreviouslyAcknowledged(lastSeen, lastRejected) 38 | } 39 | 40 | module.exports = function (client, server, options) { 41 | const mojangPubKey = crypto.createPublicKey(mojangPublicKeyPem) 42 | const raise = (translatableError) => client.end(translatableError, JSON.stringify({ translate: translatableError })) 43 | const pending = client.supportFeature('useChatSessions') ? new LastSeenMessages() : new Pending() 44 | 45 | if (!options.generatePreview) options.generatePreview = message => message 46 | 47 | function validateMessageChain (packet) { 48 | try { 49 | validateLastMessages(pending, packet.previousMessages, packet.lastRejectedMessage) 50 | } catch (e) { 51 | if (e instanceof VerificationError) { 52 | raise('multiplayer.disconnect.chat_validation_failed') 53 | if (!options.hideErrors) console.error(client.address, 'disconnected because', e) 54 | } else { 55 | client.emit('error', e) 56 | } 57 | } 58 | } 59 | 60 | function validateSession (packet) { 61 | try { 62 | const unwrapped = pending.unwrap(packet.offset, packet.acknowledged) 63 | 64 | const length = Buffer.byteLength(packet.message, 'utf8') 65 | const acknowledgements = unwrapped.length > 0 ? ['i32', unwrapped.length, 'buffer', Buffer.concat(...unwrapped)] : ['i32', 0] 66 | 67 | const signable = concat('i32', 1, 'UUID', client.uuid, 'UUID', client._session.uuid, 'i32', client._session.index++, 'i64', packet.salt, 'i64', packet.timestamp / 1000n, 'i32', length, 'pstring', packet.message, ...acknowledgements) 68 | const valid = crypto.verify('RSA-SHA256', signable, client.profileKeys.public, packet.signature) 69 | if (!valid) throw VerificationError('Invalid or missing message signature') 70 | } catch (e) { 71 | if (e instanceof VerificationError) { 72 | raise('multiplayer.disconnect.chat_validation_failed') 73 | if (!options.hideErrors) console.error(client.address, 'disconnected because', e) 74 | } else { 75 | client.emit('error', e) 76 | } 77 | } 78 | } 79 | 80 | client.on('chat_session_update', (packet) => { 81 | client._session = { 82 | index: 0, 83 | uuid: packet.sessionUuid 84 | } 85 | 86 | const publicKey = crypto.createPublicKey({ key: packet.publicKey, format: 'der', type: 'spki' }) 87 | const signable = concat('UUID', client.uuid, 'i64', packet.expireTime, 'buffer', publicKey.export({ type: 'spki', format: 'der' })) 88 | 89 | // This makes sure 'signable' when signed with the mojang private key equals signature in this packet 90 | if (!crypto.verify('RSA-SHA1', signable, mojangPubKey, packet.signature)) { 91 | debug('Signature mismatch') 92 | raise('multiplayer.disconnect.invalid_public_key_signature') 93 | return 94 | } 95 | client.profileKeys = { public: publicKey } 96 | }) 97 | 98 | // Listen to chat messages and verify the `lastSeen` and `lastRejected` messages chain 99 | let lastTimestamp 100 | client.on('chat_message', (packet) => { 101 | if (!options.enforceSecureProfile) return // nothing signable 102 | 103 | if ((lastTimestamp && packet.timestamp < lastTimestamp) || (packet.timestamp > Date.now())) { 104 | return raise('multiplayer.disconnect.out_of_order_chat') 105 | } 106 | lastTimestamp = packet.timestamp 107 | 108 | // Validate checksum for 1.21.5+ 109 | if (client.supportFeature('chatGlobalIndexAndChecksum') && options.enforceChatChecksum && packet.checksum !== undefined) { 110 | const expectedChecksum = computeChatChecksum(client._lastSeenMessages || []) 111 | if (packet.checksum !== 0 && packet.checksum !== expectedChecksum) { 112 | return raise('multiplayer.disconnect.chat_validation_failed') 113 | } 114 | } 115 | 116 | // Checks here: 1) make sure client can chat, 2) chain/session is OK, 3) signature is OK, 4) log if expired 117 | if (client.settings.disabledChat) return raise('chat.disabled.options') 118 | if (client.supportFeature('chainedChatWithHashing')) validateMessageChain(packet) // 1.19.1 119 | if (client.supportFeature('useChatSessions')) validateSession(packet) // 1.19.3 120 | else if (!client.verifyMessage(packet)) raise('multiplayer.disconnect.unsigned_chat') 121 | if ((BigInt(Date.now()) - packet.timestamp) > messageExpireTime) debug(client.socket.address(), 'sent expired message TS', packet.timestamp) 122 | }) 123 | 124 | // Client will occasionally send a list of seen messages to the server, here we listen & check chain validity 125 | client.on('message_acknowledgement', (packet) => { 126 | if (client.supportFeature('useChatSessions')) { 127 | const valid = client._lastSeenMessages.applyOffset(packet.count) 128 | if (!valid) { 129 | raise('multiplayer.disconnect.chat_validation_failed') 130 | if (!options.hideErrors) console.error(client.address, 'disconnected because', VerificationError('Failed to validate message acknowledgements')) 131 | } 132 | } else validateMessageChain(packet) 133 | }) 134 | 135 | client.verifyMessage = (packet) => { 136 | if (!client.profileKeys) return null 137 | if (client.supportFeature('useChatSessions')) throw Error('client.verifyMessage is deprecated. Does not work for 1.19.3 and above') 138 | 139 | if (client.supportFeature('chainedChatWithHashing')) { // 1.19.1 140 | if (client._lastChatSignature === packet.signature) return true // Called twice 141 | const verifier = crypto.createVerify('RSA-SHA256') 142 | if (client._lastChatSignature) verifier.update(client._lastChatSignature) 143 | verifier.update(concat('UUID', client.uuid)) 144 | 145 | // Hash of chat body now opposed to signing plaintext. This lets server give us hashes for chat 146 | // chain without needing to reveal message contents 147 | if (packet.bodyDigest) { 148 | // Header 149 | verifier.update(packet.bodyDigest) 150 | } else { 151 | // Player Chat 152 | const hash = crypto.createHash('sha256') 153 | hash.update(concat('i64', packet.salt, 'i64', packet.timestamp / 1000n, 'pstring', packet.message, 'i8', 70)) 154 | if (packet.signedPreview) hash.update(options.generatePreview(packet.message)) 155 | for (const { messageSender, messageSignature } of packet.previousMessages) { 156 | hash.update(concat('i8', 70, 'UUID', messageSender)) 157 | hash.update(messageSignature) 158 | } 159 | // Feed hash back into signing payload 160 | verifier.update(hash.digest()) 161 | } 162 | client._lastChatSignature = packet.signature 163 | return verifier.verify(client.profileKeys.public, packet.signature) 164 | } else { // 1.19 165 | const signable = concat('i64', packet.salt, 'UUID', client.uuid, 'i64', packet.timestamp, 'pstring', packet.message) 166 | return crypto.verify('sha256WithRSAEncryption', signable, client.profileKeys.public, packet.signature) 167 | } 168 | } 169 | 170 | // On 1.19.1+, outbound messages from server (client->SERVER->players) are logged so we can verify 171 | // the last seen message field in inbound chat packets 172 | client.logSentMessageFromPeer = (chatPacket) => { 173 | if (!options.enforceSecureProfile || !server.features.signedChat) return // nothing signable 174 | 175 | pending.add(chatPacket.senderUuid, chatPacket.signature, chatPacket.timestamp) 176 | if (pending.length > 4096) { 177 | raise('multiplayer.disconnect.too_many_pending_chats') 178 | return false 179 | } 180 | return true 181 | } 182 | } 183 | 184 | class LastSeenMessages extends Array { 185 | tracking = 20 186 | 187 | constructor () { 188 | super() 189 | for (let i = 0; i < this.tracking; i++) this.push(null) 190 | } 191 | 192 | add (sender, signature) { 193 | this.push({ signature, pending: true }) 194 | } 195 | 196 | applyOffset (offset) { 197 | const diff = this.length - this.tracking 198 | if (offset >= 0 && offset <= diff) { 199 | this.splice(0, offset) 200 | return true 201 | } 202 | 203 | return false 204 | } 205 | 206 | unwrap (offset, acknowledged) { 207 | if (!this.applyOffset(offset)) throw VerificationError('Failed to validate message acknowledgements') 208 | 209 | const n = (acknowledged[2] << 16) | (acknowledged[1] << 8) | acknowledged[0] 210 | 211 | const unwrapped = [] 212 | for (let i = 0; i < this.tracking; i++) { 213 | const ack = n & (1 << i) 214 | const tracked = this[i] 215 | if (ack) { 216 | if (tracked === null) throw VerificationError('Failed to validate message acknowledgements') 217 | 218 | tracked.pending = false 219 | unwrapped.push(tracked.signature) 220 | } else { 221 | if (tracked !== null && !tracked.pending) throw VerificationError('Failed to validate message acknowledgements') 222 | 223 | this[i] = null 224 | } 225 | } 226 | 227 | return unwrapped 228 | } 229 | } 230 | 231 | class Pending extends Array { 232 | m = {} 233 | lastSeen = [] 234 | 235 | get (sender, signature) { 236 | return this.m[sender]?.[signature] 237 | } 238 | 239 | add (sender, signature, ts) { 240 | this.m[sender] = this.m[sender] || {} 241 | this.m[sender][signature] = ts 242 | this.push([sender, signature]) 243 | } 244 | 245 | acknowledge (sender, username) { 246 | delete this.m[sender][username] 247 | this.splice(this.findIndex(([a, b]) => a === sender && b === username), 1) 248 | } 249 | 250 | acknowledgePrior (sender, signature) { 251 | for (let i = 0; i < this.length; i++) { 252 | const [a, b] = this[i] 253 | delete this.m[a] 254 | if (a === sender && b === signature) { 255 | this.splice(0, i) 256 | break 257 | } 258 | } 259 | } 260 | 261 | // Once we've acknowledged that the client has saw the messages we sent, 262 | // we delete it from our map & pending list. However, the client may keep it in 263 | // their 5-length lastSeen list anyway. Once we verify/ack the client's lastSeen array, 264 | // we need to store it in memory to allow those entries to be approved again without 265 | // erroring about a message we never sent in the next serverbound message packet we get. 266 | setPreviouslyAcknowledged (lastSeen, lastRejected = {}) { 267 | this.lastSeen = lastSeen.map(e => Object.values(e)).push(Object.values(lastRejected)) 268 | } 269 | 270 | previouslyAcknowledged (sender, signature) { 271 | return this.lastSeen.some(([a, b]) => a === sender && b === signature) 272 | } 273 | } 274 | --------------------------------------------------------------------------------