diff --git a/.github/CROSS_REPO_TRIGGER.md b/.github/CROSS_REPO_TRIGGER.md new file mode 100644 index 000000000..ad0ae6e88 --- /dev/null +++ b/.github/CROSS_REPO_TRIGGER.md @@ -0,0 +1,96 @@ +# Cross-Repository Workflow Trigger Setup + +This document explains how to set up cross-repository workflow triggering between the minecraft-data repository and this repository. + +## Overview + +The workflow `update-from-minecraft-data.yml` can be triggered from the minecraft-data repository in two ways: + +1. **Manual Workflow Dispatch** - Triggered manually or programmatically +2. **Repository Dispatch** - Triggered via webhook/API call + +## Setup in minecraft-data repository + +### Method 1: Workflow Dispatch (Recommended) + +Add this step to a workflow in the minecraft-data repository: + +```yaml +- name: Trigger update in node-minecraft-protocol + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.CROSS_REPO_TOKEN }} + script: | + await github.rest.actions.createWorkflowDispatch({ + owner: 'extremeheat', + repo: 'node-minecraft-protocol', + workflow_id: 'update-from-minecraft-data.yml', + ref: 'master', // or the target branch + inputs: { + trigger_source: 'minecraft-data', + trigger_reason: 'data_update', + data_version: '${{ steps.get_version.outputs.version }}' // or your version variable + } + }); +``` + +### Method 2: Repository Dispatch + +```yaml +- name: Trigger update in node-minecraft-protocol + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.CROSS_REPO_TOKEN }} + script: | + await github.rest.repos.createDispatchEvent({ + owner: 'extremeheat', + repo: 'node-minecraft-protocol', + event_type: 'minecraft-data-update', + client_payload: { + repository: 'minecraft-data', + reason: 'data_update', + version: '${{ steps.get_version.outputs.version }}' + } + }); +``` + +## Required Secrets + +You need to create a Personal Access Token (PAT) with the following permissions: +- `repo` scope (for private repositories) +- `public_repo` scope (for public repositories) +- `actions:write` permission + +Add this token as a secret named `CROSS_REPO_TOKEN` in the minecraft-data repository. + +## Token Setup Steps + +1. Go to GitHub Settings > Developer settings > Personal access tokens > Tokens (classic) +2. Generate a new token with appropriate permissions +3. Add the token as `CROSS_REPO_TOKEN` secret in minecraft-data repository settings + +## Customizing the Updator Script + +The updator script (`.github/helper/updator.js`) can be customized to: + +- Download and process minecraft-data updates +- Update protocol definitions +- Run tests to verify compatibility +- Create pull requests for review +- Send notifications + +## Testing + +You can test the workflow manually by: + +1. Going to the Actions tab in this repository +2. Selecting "Update from minecraft-data" workflow +3. Clicking "Run workflow" +4. Providing test inputs + +## Security Considerations + +- Use repository secrets for sensitive tokens +- Limit token permissions to minimum required +- Consider using short-lived tokens or GitHub Apps for enhanced security +- Review and approve automatic commits/PRs if needed diff --git a/.github/helper/package.json b/.github/helper/package.json new file mode 100644 index 000000000..5f88a010b --- /dev/null +++ b/.github/helper/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "gh-helpers": "^1.0.0" + } +} \ No newline at end of file diff --git a/.github/helper/updator.js b/.github/helper/updator.js new file mode 100644 index 000000000..9245b80f2 --- /dev/null +++ b/.github/helper/updator.js @@ -0,0 +1,100 @@ +#!/usr/bin/env node +/** + * Updator script triggered from minecraft-data repository to auto generate PR + */ +const fs = require('fs') +const cp = require('child_process') +const assert = require('assert') +const github = require('gh-helpers')() +const { join } = require('path') +const exec = (cmd) => github.mock ? console.log('> ', cmd) : (console.log('> ', cmd), cp.execSync(cmd, { stdio: 'inherit' })) + +console.log('Starting update process...') +// Sanitize and validate environment variables all non alpha numeric / underscore / dot +const newVersion = process.env.NEW_MC_VERSION?.replace(/[^a-zA-Z0-9_.]/g, '_') +const triggerBranch = process.env.MCDATA_BRANCH?.replace(/[^a-zA-Z0-9_.]/g, '_') +const mcdataPrURL = process.env.MCDATA_PR_URL +console.log({ newVersion, triggerBranch, mcdataPrURL }) + +assert(newVersion) +assert(triggerBranch) + +async function main () { + const currentSupportedPath = require.resolve('../../src/version.js') + const readmePath = join(__dirname, '../../docs/README.md') + const ciPath = join(__dirname, '../../.github/workflows/ci.yml') + + // Update the version.js + const currentSupportedVersion = require('../../src/version.js') + const currentContents = fs.readFileSync(currentSupportedPath, 'utf8') + console.log('Current supported version:', currentContents) + const newContents = currentContents.includes(newVersion) + ? currentContents + : currentContents + .replace(`: '${currentSupportedVersion.defaultVersion}'`, `: '${newVersion}'`) + .replace(`, '${currentSupportedVersion.defaultVersion}'`, `, '${currentSupportedVersion.defaultVersion}', '${newVersion}'`) + + // Update the README.md + const currentContentsReadme = fs.readFileSync(readmePath, 'utf8') + if (!currentContentsReadme.includes(newVersion)) { + const newReadmeContents = currentContentsReadme.replace('\n', `, ${newVersion}\n`) + fs.writeFileSync(readmePath, newReadmeContents) + console.log('Updated README with new version:', newVersion) + } + fs.writeFileSync(currentSupportedPath, newContents) + + // Update the CI workflow + const currentContentsCI = fs.readFileSync(ciPath, 'utf8') + if (!currentContentsCI.includes(newVersion)) { + const newCIContents = currentContentsCI.replace( + 'run: npm install', `run: npm install + - 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 + - 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 +`) + fs.writeFileSync(ciPath, newCIContents) + console.log('Updated CI workflow with new version:', newVersion) + } + + const branchName = 'pc' + newVersion.replace(/[^a-zA-Z0-9_]/g, '_') + exec(`git checkout -b ${branchName}`) + exec('git config user.name "github-actions[bot]"') + exec('git config user.email "41898282+github-actions[bot]@users.noreply.github.com"') + exec('git add --all') + exec(`git commit -m "Update to version ${newVersion}"`) + exec(`git push origin ${branchName} --force`) + // createPullRequest(title: string, body: string, fromBranch: string, intoBranch?: string): Promise<{ number: number, url: string }>; + const pr = await github.createPullRequest( + `🎈 ${newVersion}`, + `This automated PR sets up the relevant boilerplate for Minecraft version ${newVersion}. + +Ref: ${mcdataPrURL} + +* You can help contribute to this PR by opening a PR against this ${branchName} branch instead of master. + `, + branchName, + 'master' + ) + console.log(`Pull request created`, pr) + + // Ask mineflayer to handle new update + const nodeDispatchPayload = { + owner: 'PrismarineJS', + repo: 'mineflayer', + workflow: 'handle-update.yml', + branch: 'master', + inputs: { + new_mc_version: newVersion, + mcdata_branch: triggerBranch, + mcdata_pr_url: mcdataPrURL, + nmp_branch: branchName, + nmp_pr_url: pr.url + } + } + console.log('Sending workflow dispatch', nodeDispatchPayload) + await github.sendWorkflowDispatch(nodeDispatchPayload) +} + +main().catch(err => { + console.error('Error during update process:', err) + process.exit(1) +}) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2d44c8714..2c80125a2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,39 +9,52 @@ on: - master jobs: - test: + Lint: runs-on: ubuntu-latest - strategy: - matrix: - mcVersion: ['1.7', '1.8', '1.9', '1.10', '1.11.2', '1.12.2', '1.13.2', '1.14.4', '1.15.2', '1.16.5', '1.17', '1.17.1', '1.18'] steps: - uses: actions/checkout@v2 - - name: Use Node.js 14.x - uses: actions/setup-node@v1 + - name: Use Node.js 22.x + uses: actions/setup-node@v1.4.4 with: - node-version: 14.x - - name: Setup Java JDK - uses: actions/setup-java@v1.4.3 + node-version: 22.x + - run: npm i && npm run lint + PrepareSupportedVersions: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.set-matrix.outputs.matrix }} + + steps: + - uses: actions/checkout@v2 + - name: Use Node.js 22.x + uses: actions/setup-node@v1.4.4 with: - java-version: '17' - distribution: 'adopt' - - name: Install dependencies - run: npm install - - name: Run tests - run: npm test -- -g ${{ matrix.mcVersion }} - packet-cycle-test: + node-version: 22.x + - id: set-matrix + run: | + node -e " + const supportedVersions = require('./src/version').supportedVersions; + console.log('matrix='+JSON.stringify({'include': supportedVersions.map(mcVersion => ({mcVersion}))})) + " >> $GITHUB_OUTPUT + test: + needs: PrepareSupportedVersions runs-on: ubuntu-latest + strategy: + matrix: ${{fromJson(needs.PrepareSupportedVersions.outputs.matrix)}} + fail-fast: false steps: - uses: actions/checkout@v2 - - name: Use Node.js 14.x + - name: Use Node.js 22.x uses: actions/setup-node@v1 with: - node-version: 14.x + node-version: 22.x - name: Setup Java JDK uses: actions/setup-java@v1.4.3 with: - java-version: '16' + java-version: '21' distribution: 'adopt' - - run: npm install && npm run test-non-par + - name: Install dependencies + run: npm install + - name: Run tests + run: npm run mochaTest -- -g ${{ matrix.mcVersion }}v diff --git a/.github/workflows/commands.yml b/.github/workflows/commands.yml new file mode 100644 index 000000000..40807af7f --- /dev/null +++ b/.github/workflows/commands.yml @@ -0,0 +1,24 @@ +name: Repo Commands + +on: + issue_comment: # Handle comment commands + types: [created] + pull_request: # Handle renamed PRs + types: [edited] +permissions: + contents: write + +jobs: + comment-trigger: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v3 + - name: Run command handlers + uses: PrismarineJS/prismarine-repo-actions@master + with: + # 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. + token: ${{ secrets.PAT_PASSWORD }} + # See `Options` section below for more info on these options + install-command: npm install + /fixlint.fix-command: npm run fix \ No newline at end of file diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 7e90156fc..f5e18ab9c 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -13,13 +13,20 @@ jobs: - name: Set up Node.js uses: actions/setup-node@master with: - node-version: 10.0.0 - - name: Publish if version has been updated - uses: pascalgn/npm-publish-action@4f4bf159e299f65d21cd1cbd96fc5d53228036df - with: # All of theses inputs are optional - tag_name: "%s" - tag_message: "%s" - commit_pattern: "^Release (\\S+)" - env: # More info about the environment variables in the README - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Leave this as is, it's automatically generated - NPM_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} # You need to set this in your repo settings + node-version: 22.0.0 + - id: publish + uses: JS-DevTools/npm-publish@v1 + with: + token: ${{ secrets.NPM_AUTH_TOKEN }} + - name: Create Release + if: steps.publish.outputs.type != 'none' + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ steps.publish.outputs.version }} + release_name: Release ${{ steps.publish.outputs.version }} + body: ${{ steps.publish.outputs.version }} + draft: false + prerelease: false \ No newline at end of file diff --git a/.github/workflows/update-from-minecraft-data.yml b/.github/workflows/update-from-minecraft-data.yml new file mode 100644 index 000000000..752aad543 --- /dev/null +++ b/.github/workflows/update-from-minecraft-data.yml @@ -0,0 +1,36 @@ +name: Update from minecraft-data + +on: + workflow_dispatch: + inputs: + new_mc_version: + description: New minecraft version number + required: true + type: string + mcdata_branch: + description: minecraft-data branch for this version + required: true + type: string + mcdata_pr_url: + description: minecraft-data PR number to open a PR here against + required: false + default: '' + type: string + +jobs: + update: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + token: ${{ secrets.PAT_PASSWORD }} + + - name: Run updator script + run: cd .github/helper && npm install && node updator.js + env: + GITHUB_TOKEN: ${{ secrets.PAT_PASSWORD }} + MCDATA_BRANCH: ${{ github.event.inputs.mcdata_branch }} + MCDATA_PR_URL: ${{ github.event.inputs.mcdata_pr_url }} + NEW_MC_VERSION: ${{ github.event.inputs.new_mc_version }} diff --git a/.gitignore b/.gitignore index 9afc68587..2661e97d9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules test/npm-debug.log -test/server* +test/server_* package-lock.json versions/ -src/client/*.json \ No newline at end of file +src/client/*.json +test_* \ No newline at end of file diff --git a/.gitpod.yml b/.gitpod.yml index 38fc373b5..13f366c56 100644 --- a/.gitpod.yml +++ b/.gitpod.yml @@ -1,2 +1,2 @@ tasks: -- command: npm install +- command: npm install && sdk install java diff --git a/docs/API.md b/docs/API.md index 787a129d6..88067bcaf 100644 --- a/docs/API.md +++ b/docs/API.md @@ -12,21 +12,29 @@ automatically logged in and validated against mojang's auth. * kickTimeout : default to `10*1000` (10s), kick client that doesn't answer to keepalive after that time * checkTimeoutInterval : default to `4*1000` (4s), send keepalive packet at that period * online-mode : default to true - * beforePing : allow customisation of the answer to ping the server does. + * beforePing : allow customisation of the answer to ping the server does. It takes a function with argument response and client, response is the default json response, and client is client who sent a ping. It can take as third argument a callback. If the callback is passed, the function should pass its result to the callback, if not it should return. + If the result is `false` instead of a response object then the connection is terminated and no ping is returned to the client. * beforeLogin : allow customisation of client before the `success` packet is sent. It takes a function with argument client and should be synchronous for the server to wait for completion before continuing execution. * motd : default to "A Minecraft server" + * motdMsg : A json object of the chat message to use instead of `motd`. Can be build using [prismarine-chat](https://github.com/PrismarineJS/prismarine-chat) and calling .toJSON(). Not used with legacy pings. * maxPlayers : default to 20 * keepAlive : send keep alive packets : default to true - * version : 1.8 or 1.9 : default to 1.8 + * version : the version of the server, defaults to the latest version. Set version to `false` to enable dynamic cross version support. + * fallbackVersion (optional) : the version that should be used as a fallback, if the client version isn't supported, only works with dynamic cross version support. * favicon (optional) : the favicon to set, base64 encoded * customPackets (optional) : an object index by version/state/direction/name, see client_custom_packet for an example * errorHandler : A way to override the default error handler for client errors. A function that takes a Client and an error. The default kicks the client. * hideErrors : do not display errors, default to false - * agent : a http agent that can be used to set proxy settings for yggdrasil authentication confirmation (see proxy-agent on npm) + * agent : a http agent that can be used to set proxy settings for yggdrasil authentication confirmation (see proxy-agent on npm) + * validateChannelProtocol (optional) : whether or not to enable protocol validation for custom protocols using plugin channels for the connected clients. Defaults to true + * enforceSecureProfile (optional) : Kick clients that do not have chat signing keys from Mojang (1.19+) + * generatePreview (optional) : Function to generate chat previews. Takes the raw message string and should return the message preview as a string. (1.19-1.19.2) + * socketType (optional) : either `tcp` or `ipc`. Switches from a tcp connection to a ipc socket connection (or named pipes on windows). With the `ipc` option `host` becomes the path off the ipc connection on the local filesystem. Example: `\\.\pipe\minecraft-ipc` (Windows) `/tmp/minecraft-ipc.sock` (unix based systems). See the ipcConnection example for an example. + * Server : You can pass a custom server class to use instead of the default one. ## mc.Server(version,[customPackets]) @@ -36,6 +44,14 @@ Create a server instance for `version` of minecraft. Write a packet to all `clients` but encode it only once. +### client.verifyMessage(packet) : boolean + +(1.19-1.19.2) Verifies if player's chat message packet was signed with their Mojang provided key. Handled internally (and thus deprecated) in 1.19.3 and above + +### client.logSentMessageFromPeer(packet) +(1.19.1+) You must call this function 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. For more information, see [chat.md](chat.md). + ### server.onlineModeExceptions This is a plain old JavaScript object. Add a key with the username you want to @@ -73,6 +89,19 @@ Called when a client connects, but before any login has happened. Takes a Called when a client is logged in against server. Takes a `Client` parameter. +### `playerJoin` event + +Emitted after a player joins and enters the PLAY protocol state and can send and recieve game packets. This is emitted after the `login` event. On 1.20.2 and above after we emit the `login` event, the player will enter a CONFIG state, as opposed to the PLAY state (where game packets can be sent), so you must instead now wait for `playerJoin`. + + +### `listening` event + +Called when the server is listening for connections. This means that the server is ready to accept incoming connections. + +### `close` event + +Called when the server is no longer listening to incoming connections. + ## mc.createClient(options) @@ -81,7 +110,7 @@ Returns a `Client` instance and perform login. `options` is an object containing the properties : * username * port : default to 25565 - * auth : the type of account to use, either `microsoft` or `mojang`. default to 'mojang' + * auth : the type of account to use, either `microsoft`, `mojang`, `offline` or `function (client, options) => void`. defaults to 'offline'. * password : can be omitted * (microsoft account) leave this blank to use device code auth. If you provide a password, we try to do username and password auth, but this does not always work. @@ -89,7 +118,7 @@ Returns a `Client` instance and perform login. is blank, and `profilesFolder` is specified, we auth with the tokens there instead. If neither `password` or `profilesFolder` are specified, we connect in offline mode. * host : default to localhost - * session : An object holding clientToken, accessToken and selectedProfile. Generated after logging in using username + password with mojang auth or after logging in using microsoft auth. `clientToken`, `accessToken` and `selectedProfile: {name: '', id: ''}` can be set inside of `session` when using createClient to login with a client and access Token instead of a password. `session` is also emitted by the `Client` instance with the event 'session' after successful authentication. + * session : An object holding clientToken, accessToken and selectedProfile. Generated after logging in using username + password with mojang auth or after logging in using microsoft auth. `clientToken`, `accessToken` and `selectedProfile: {name: '', id: ''}` can be set inside of `session` when using createClient to login with a client and access Token instead of a password. `session` is also emitted by the `Client` instance with the event 'session' after successful authentication. * clientToken : generated if a password is given or can be set when when using createClient * accessToken : generated if a password or microsoft account is given or can be set when using createBot * selectedProfile : generated if a password or microsoft account is given. Can be set as a object with property `name` and `id` that specifies the selected profile. @@ -106,16 +135,22 @@ Returns a `Client` instance and perform login. * hideErrors : do not display errors, default to false * skipValidation : do not try to validate given session, defaults to false * stream : a stream to use as connection - * connect : a function taking the client as parameter and that should client.setSocket(socket) + * connect : a function taking the client as parameter and that should client.setSocket(socket) and client.emit('connect') when appropriate (see the proxy examples for an example of use) - * agent : a http agent that can be used to set proxy settings for yggdrasil authentication (see proxy-agent on npm) + * agent : a http agent that can be used to set proxy settings for yggdrasil authentication (see proxy-agent on npm) * fakeHost : (optional) hostname to send to the server in the set_protocol packet * profilesFolder : optional - * (mojang account) the path to the folder that contains your `launcher_profiles.json`. defaults to your minecraft folder if it exists, otherwise the local directory. set to `false` to disable managing profiles + * (mojang account) the path to the folder that contains your `launcher_profiles.json`. defaults to your minecraft folder if it exists, otherwise the local directory. set to `false` to disable managing profiles * (microsoft account) the path to store authentication caches, defaults to .minecraft * onMsaCode(data) : (optional) callback called when signing in with a microsoft account with device code auth. `data` is an object documented [here](https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-device-code#device-authorization-response) * id : a numeric client id used for referring to multiple clients in a server + * validateChannelProtocol (optional) : whether or not to enable protocol validation for custom protocols using plugin channels. Defaults to true + * disableChatSigning (optional) : Don't try obtaining chat signing keys from Mojang (1.19+) + * realms : An object which should contain one of the following properties: `realmId` or `pickRealm`. When defined will attempt to join a Realm without needing to specify host/port. **The authenticated account must either own the Realm or have been invited to it** + * realmId : The id of the Realm to join. + * pickRealm(realms) : A function which will have an array of the user Realms (joined/owned) passed to it. The function should return a Realm. + * Client : You can pass a custom client class to use instead of the default one, which would allow you to create completely custom communication. Also note that you can use the `stream` option instead where you can supply custom duplex, but this will still use serialization/deserialization of packets. ## mc.Client(isServer,version,[customPackets]) @@ -203,11 +238,11 @@ The client's protocol version ### client.version -The client's version +The client's version, as a string ### `packet` event -Called with every packet parsed. Takes four paramaters, the JSON data we parsed, the packet metadata (name, state), the buffer (raw data) and the full buffer (includes surplus data and may include the data of following packets on versions below 1.8) +Called with every packet parsed. Takes four paramaters, the JSON data we parsed, the packet metadata (name, state), the buffer (raw data) and the full buffer (includes surplus data and may include the data of following packets on versions below 1.8) ### `raw` event @@ -231,10 +266,35 @@ Called when user authentication is resolved. Takes session data as parameter. Called when the protocol changes state. Takes the new state and old state as parameters. +### `playerJoin` event + +Emitted after the player enters the PLAY protocol state and can send and recieve game packets + ### `error` event Called when an error occurs within the client. Takes an Error as parameter. +### `playerChat` event + +Called when a chat message from another player arrives. The emitted object contains: +* formattedMessage -- (JSON) the chat message preformatted, if done on server side +* plainMessage -- (Plaintext) the chat message without formatting (for example no ` message` ; instead `message`), on version 1.19+ +* unsignedContent -- (JSON) unsigned formatted chat contents ; should only be present when the message is modified and server has chat previews disabled - only on version 1.19 - 1.19.2 +* type -- the message type - on 1.19, which format string to use to render message ; below, the place where the message is displayed (for example chat or action bar) +* sender -- the UUID of the player sending the message +* senderTeam -- scoreboard team of the player (pre 1.19) +* senderName -- Name of the sender +* targetName -- Name of the target (for outgoing commands like /tell). Only in 1.19.2+ +* verified -- true if message is signed, false if not signed, undefined on versions prior to 1.19 + +### `systemChat` event + +Called when a system chat message arrives. A system chat message is any message not sent by a player. The emitted object contains: +* formattedMessage -- (JSON) the chat message preformatted +* positionId -- the chat type of the message. 1 for system chat and 2 for actionbar + +See the [chat example](https://github.com/PrismarineJS/node-minecraft-protocol/blob/master/examples/client_chat/client_chat.js#L1) for usage. + ### per-packet events Check out the [minecraft-data docs](https://prismarinejs.github.io/minecraft-data/?v=1.8&d=protocol) to know the event names and data field names. @@ -255,6 +315,16 @@ Start emitting channel events of the given name on the client object. Unregister a channel `name` and send the unregister packet if `custom` is true. +### client.chat(message) +Send a chat message to the server, with signing on 1.19+. + +### client.signMessage(message: string, timestamp: BigInt, salt?: number, preview?: string, acknowledgements?: Buffer[]) : Buffer + +(1.19) Generate a signature for a chat message to be sent to server + +### client.verifyMessage(publicKey: Buffer | KeyObject, packet) : boolean + +(1.19) Verifies a player chat packet sent by another player against their public key ## Not Immediately Obvious Data Type Formats @@ -323,6 +393,10 @@ The minecraft protocol states. The supported minecraft versions. +## mc.defaultVersion + +The current default minecraft version. + ## mc.createSerializer({ state = states.HANDSHAKING, isServer = false , version}) Returns a minecraft protocol [serializer](https://github.com/roblabla/ProtoDef#serializerprotomaintype) for these parameters. diff --git a/docs/CNAME b/docs/CNAME deleted file mode 100644 index cb9ce8606..000000000 --- a/docs/CNAME +++ /dev/null @@ -1 +0,0 @@ -node-minecraft-protocol.prismarine.js.org diff --git a/docs/FAQ.md b/docs/FAQ.md index eac88a4b3..7fa9e714e 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -1,20 +1,29 @@ -## FAQ +# FAQ This Frequently Asked Question document is meant to help people for the most common things. -### How to hide errors ? +## How to hide errors ? Use `hideErrors: true` in createClient options You may also choose to add these listeners : + ```js client.on('error', () => {}) client.on('end', () => {}) ``` -### How can I make a proxy with this ? +## How can I make a proxy with this ? + +* Check out our WIP proxy lib +* See this example +* Read this issue +* check out +* Check out this app + +## Can you support alternative auth methods? + +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. + +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. -* Check out our WIP proxy lib https://github.com/PrismarineJS/prismarine-proxy -* See this example https://github.com/PrismarineJS/node-minecraft-protocol/tree/master/examples/proxy -* Read this issue https://github.com/PrismarineJS/node-minecraft-protocol/issues/712 -* check out https://github.com/Heath123/pakkit -* Check out this app https://github.com/wvffle/minecraft-packet-debugger +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. diff --git a/docs/HISTORY.md b/docs/HISTORY.md index 53f962e29..b5e08d096 100644 --- a/docs/HISTORY.md +++ b/docs/HISTORY.md @@ -1,5 +1,209 @@ # History +## 1.62.0 +* [Add support for Minecraft 1.21.8 (#1427)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/8a99613672298b2e9a1e66ca41f8bb720cf1a439) (thanks @rom1504) + +## 1.61.0 +* [1.21.6 (#1416)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/0bfd970e954f1ab5e579ebe51cbefbf07d952939) (thanks @extremeheat) + +## 1.60.1 +* [Fix undefined chat message signatures causing crash (#1413)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/2119b04f52605f62df2d029908d92d6443aa68db) (thanks @PiotrW01) +* [types: add missing config state (#1397)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/5242498797e1e9877f9b0056ff608fb68e0a3508) (thanks @notsapinho) +* [fix leak on entering config state twice (#1381)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/74aea5a5d6a495bfdfc7e97b164524f6a0c6a203) (thanks @zardoy) + +## 1.60.0 +* [1.21.5 (#1408)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/2467716b6f52d2c83a6c2173bcfa686d4a437868) (thanks @extremeheat) + +## 1.59.0 +* [fix: Wrap base64-encoded PEM with 64-char line boundary (#1292)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/0f6da566975a262e4e6f12a11e9b311496682508) (thanks @littledivy) + +## 1.58.0 +* [Fixes to protocol Holder implementation (#1355)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/7207b61f3a809ec9db01869a90c5ccaeafee4ca1) (thanks @extremeheat) +* [Bump @types/node from 22.15.33 to 24.0.4 (#1405)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/1e38d8fc1e1bbe7aa834055cfd38ed0fa22c2085) (thanks @dependabot[bot]) + +## 1.57.0 +* [Update package.json to remove lodash.get](https://github.com/PrismarineJS/node-minecraft-protocol/commit/031f13fea45264775311ae82e5a4efe74ebba96d) (thanks @rom1504) + +## 1.56.0 +* [Update serializer.js to remove usage of lodash.get (#1390)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/6a445312d384a7ca739e29d61bc37e4525da21e4) (thanks @rom1504) + +## 1.55.0 +* [Fix `client.end()` (#1376)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/3bd4dc1b2002cd7badfa5b9cf8dda35cd6cc9ac1) (thanks @h5mcbox) +* [Fix #1369 online-mode error 1.20.5-1.21.4 (#1375)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/5ec3dd4b367fcc039fbcb3edd214fe3cf8178a6d) (thanks @h5mcbox) +* [Update to node 22 (#1371)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/e9eb551ba30ec2e742c49e6927be6402b413bb76) (thanks @rom1504) +* [Add npm update to version error message](https://github.com/PrismarineJS/node-minecraft-protocol/commit/080aa52c5bd70a5f9c4ecc37480497dd335a9e83) (thanks @extremeheat) +* [Add `npm update` to version error message](https://github.com/PrismarineJS/node-minecraft-protocol/commit/c9cf36354914a57bac9e17e2076670b37c04d4a9) (thanks @extremeheat) + +## 1.54.0 +* [fix: use node-rsa for decryption for higher node compatibility (#1319)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/c879d0e753f4f16fe5889ba53c9c004cc8832a56) (thanks @jacobk999) + +## 1.53.0 +* [1.21.4 (#1366)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/e74d11f66a835c08337b47dc5a2a7848c7e6e94c) (thanks @extremeheat) +* [Bump mocha from 10.8.2 to 11.0.1 (#1352)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/502513b432695bd9e0fdff039bd8a7de02b307e0) (thanks @dependabot[bot]) + +## 1.52.0 +* [Fix server_data payload for 1.19+, fix kicks messages on 1.20.3+ (#1364)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/8e131c359ebd5509136fd849a82cc59cd0dc1e58) (thanks @extremeheat) + +## 1.51.0 +* [Add type to serverKey in server (#1349)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/d6b4e82eb170984380e7ea9f125ea5d72777bef2) (thanks @u9g) +* [support 1.21.3 (#1347)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/2224d824065908e910520dfa8ea9f3f3ade242e4) (thanks @rom1504) +* [Bump @types/node from 20.16.15 to 22.7.9 (#1345)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/590dc33fed2100e77ef58e7db716dfc45eb61159) (thanks @dependabot[bot]) + +## 1.50.0 +* [1.21 Support (#1342)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/5bebac36620d8f8ec256d19483e20e643d63de2a) (thanks @GroobleDierne) + +## 1.49.0 +* [support 1.20.6 (#1338)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/0b0012d60f0f1648be5ff705e7694bb1cd4ec37c) (thanks @rom1504) + +## 1.48.0 +* [1.20.5 (#1309)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/9b029e8b6f33d4e8ee1476de6821bad942f1ab6b) (thanks @extremeheat) +* [Fix realms loading issue due to createClient plugin init order (#1303)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/7057ad979b416192ada235f2f4e3b5eb26af5fa1) (thanks @extremeheat) +* [Update doc (#1300)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/495eed56ab230b2615596590064671356d86a2dc) (thanks @extremeheat) +* [Fix handling of disconnect in versionChecking on 1.20.3+. (#1291)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/ccab9fb39681f3ebe0d264e2a3f833aa3c5a1ac7) (thanks @wgaylord) + +## 1.47.0 +* [1.20.3 / 1.20.4 support (#1275)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/1d9a38253a28a515d82fffa13806cb0874c5b36c) (thanks @wgaylord) + +## 1.46.0 +* [Ensure `onReady` in client is called once (#1287)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/85a26a52944c89af273bc974380b438073280981) (thanks @extremeheat) +* [Acknowledge returning to configuration state if in play state. (#1284)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/092e10c53d33a7b9be52b5cbb67b1e3e55ac2690) (thanks @wgaylord) +* [Allow commands not to be signed (#1277)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/21240f8ab2fd41c76f50b64e3b3a945f50b25b5e) (thanks @forester302) +* [Add test to make sure version that are tested are mentioned in the RE… (#1276)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/80d038bd61d1933daa1e5e3251635be9ce2116b6) (thanks @rom1504) +* [Print if there is a diff in packets in the cycle packet test (#1273)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/614be919d0f20a43e238751c829a6d584ae636cd) (thanks @rom1504) +* [Align supported versions with mineflayer (#1272)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/ccaf538ffd2ab1e25dabd752d721f97bd8bd188f) (thanks @rom1504) + +## 1.45.0 +* [Pc1.20.2 (#1265)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/112926da0cb2490934d122dd8ed7b79f3f6de8eb) (thanks @extremeheat) +* [Improve CI setup for per version tests (#1267)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/1740124c4722c2c49f8aed0d708ff5ebecc7743c) (thanks @rom1504) +* [Allow to create custom client & communication between clients (#1254)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/9e991094761d51243cb28a33bb45630f3064511d) (thanks @zardoy) +* [Fixed 'unsignedContent' field using nonexistent 'packet.unsignedContent' when emitting 'playerChat' event. (#1263)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/066a2b3646cb8bef6be1fa974597b975aaf08d42) (thanks @Ynfuien) +* [Add chat typing to client (#1260)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/788bff289030fa66c980de82d82cb953bf76332b) (thanks @IceTank) +* [chat: Only sign command args when profile keys defined (#1257)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/0ac8c087a28b3ccc73f8eea5941e4902e33c494e) (thanks @evan-goode) + +## 1.44.0 +* [Send chat commands as chat commands instead of chat messages for 1.19.3-1.20.1 (#1241)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/41f9e4ac4a35b0ce241264a3f964c4874d96a119) (thanks @lkwilson) +* [Fix end bundle bundle_delimiter packet not being emitted (#1248)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/35b2aa536a4739c11fe78f6e8e5c591abd0b0498) (thanks @PondWader) +* [Bump @types/readable-stream from 2.3.15 to 4.0.0 (#1247)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/94b9c228b07bbaf210aa9f90ab240cb6aa9d7751) (thanks @dependabot[bot]) +* [fix broken link (#1243)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/cc9aa9416101407421bdd085002ec2b26ccfbc83) (thanks @FurriousFox) +* [Add command gh workflow allowing to use release command in comments (#1244)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/1a4cfa7f5ee1a896b6a924708536d3f956cb869e) (thanks @rom1504) + +## 1.43.2 + +* Fix client sending chat_session_update when local UUID does not match UUID on server (@frej4189) + +## 1.43.1 + +* Temporarily make node 18 not required in package.json + +## 1.43.0 + +* 1.20.0 and .1 support (@PondWader) + +## 1.42.0 + +* 1.19.4 support (@extremeheat) +* Fix plugin channels support (@turikhay) +* Typo in "cypher" property check (@XHawk87) +* Add ipc connection option for servers (@IceTank) +* bug fix (@extremeheat) + +## 1.41.2 +* Fix client sending session packet while server is in login state (@frej4189) +* Fix client attempting to sign messages on offline servers (@frej4189) + +## 1.41.1 +* Revert "Fix client sending chat_session packet before server state transition" + +## 1.41.0 + +* Catch errors in custom payloads (@frej4189) +* Fix client sending session packet when server is in offline mode (@frej4189) +* Fix client sending chat_session packet before server state transition (@frej4189) + +## 1.40.3 +* Use consistent parameter naming for systemChat event + +## 1.40.2 +* Small chat.js fix (@frej4189) + +## 1.40.1 +* Fix offline mode (@frej4189) + +## 1.40.0 +* Add more fields to playerChat event (@frej4189) +* Update to 1.19.3 (@frej4189) + +## 1.39.0 +* Use non-zero salt (@frej4189) +* Mark message as insecure if unsigned content is present (@frej4189) + +## 1.38.1 +* Update chat example for 1.19 (#1059) (@frej4189) +* Fix chat not working on offline servers (#1060) (@frej4189) + +## 1.38.0 +* Update convenience chat events (@frej4189) +* Realm Joining (@LucienHH ) +* Fix chat previews not working (@frej4189) + +## 1.37.0 +* 1.19.1/2 signed chat support (@frej4189 @extremeheat) + +## 1.36.2 +* Throw error on minecraft-data protocol version mismatch (#1044) +* Make "fakeHost" option working +* Update microsoftAuth to set default flow option + +## 1.36.1 + +* Fix new types not being optional. (@IceTank) [#1033](https://github.com/PrismarineJS/node-minecraft-protocol/pull/1033) + +## 1.36.0 + +* Use offline mode as default authentication, fallback to offline mode if invalid option. (@Kashalls) +* Provide interface for using not at all supported alternative accounts. (@Kashalls) +* 1.19 support (@extremeheat) +* Fix unhandled promise rejection on ms auth (@IceTank) + +## 1.35.1 + +* add custom minecraft type varlong which aliases to varint @rob9315 + +## 1.35.0 + +* Add option to not answer to pings (@matthi4s) +* Add fallback version for dynamic version (@matthi4s) +* Add motdMsg to createServer (@IceTank & @U9G) +* Bump mocha to 10.x +* Bump standard to 17.x + +## 1.34.0 + +* Export defaultVersion (@matthi4s) +* Fix missing readable-stream types (@IceTank) + +## 1.33.0 + +* Bump mcdata + +## 1.32.2 + +* fix: cross version ping + +## 1.32.1 + +* fix protocolValidation not being optional in .d.ts typings (@IceTank) + +## 1.32.0 + +* add protocolValidation field to server and client options (@RichardDorian) +* fix plugin channel registration (@RichardDorian) +* allows false value for profilesFolder (@Robbilie) + +## 1.31.0 + +* 1.18.2 + ## 1.30.0 * add reasons for client.end() & fix issues (@U5B) diff --git a/docs/README.md b/docs/README.md index ca25145f6..028e05bf7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,9 +11,16 @@ Parse and serialize minecraft packets, plus authentication and encryption. ## Features - * Supports Minecraft PC version 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), 1.11 (16w35a, 1.11, 1.11.2), 1.12 (17w15a, 17w18b, 1.12-pre4, 1.12, 1.12.1, 1.12.2), and 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) - , 1.15 (1.15, 1.15.1, 1.15.2) and 1.16 (20w13b, 20w14a, 1.16-rc1, 1.16, 1.16.1, 1.16.2, 1.16.3, 1.16.4), 1.17 (21w07a, 1.17, 1.17.1), 1.18 + * Supports Minecraft PC version + 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), + 1.11 (16w35a, 1.11, 1.11.2), 1.12 (17w15a, 17w18b, 1.12-pre4, 1.12, 1.12.1, 1.12.2), + 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), + 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), + 1.17 (21w07a, 1.17, 1.17.1), 1.18 (1.18, 1.18.1 and 1.18.2), + 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), + 1.21, 1.21.1, 1.21.3, 1.21.4, 1.21.5, 1.21.6, 1.21.8 + + * Parses all packets and emits events with packet fields as JavaScript objects. * Send a packet by supplying fields as a JavaScript object. @@ -22,7 +29,8 @@ Parse and serialize minecraft packets, plus authentication and encryption. - Encryption - Compression - Both online and offline mode - - Respond to keep-alive packets. + - Respond to keep-alive packets + - Follow DNS service records (SRV) - Ping a server for status * Server - Online/Offline mode @@ -51,9 +59,10 @@ node-minecraft-protocol is pluggable. create bots. * [flying-squid](https://github.com/PrismarineJS/flying-squid) - Create minecraft servers with a high level API, also a minecraft server by itself. - * [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 - * [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 - * [aresrpg](https://github.com/aresrpg/aresrpg) - An open-source mmorpg minecraft server + * [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. + * [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. + * [aresrpg](https://github.com/aresrpg/aresrpg) - An open-source mmorpg minecraft server. + * [SteveProxy](https://github.com/SteveProxy/proxy) - Proxy for Minecraft with the ability to change the gameplay using plugins. * and [several thousands others](https://github.com/PrismarineJS/node-minecraft-protocol/network/dependents?package_id=UGFja2FnZS0xODEzMDk0OQ%3D%3D) ## Installation @@ -64,62 +73,84 @@ node-minecraft-protocol is pluggable. * [API doc](API.md) * [faq](FAQ.md) -* [protocol doc](https://minecraft-data.prismarine.js.org/?d=protocol) and [wiki.vg/Protocol](https://wiki.vg/Protocol) +* [protocol doc](https://prismarinejs.github.io/minecraft-data/?d=protocol) and [wiki.vg/Protocol](https://wiki.vg/Protocol) ## Usage ### Echo client example ```js -var mc = require('minecraft-protocol'); -var client = mc.createClient({ +const mc = require('minecraft-protocol'); +const client = mc.createClient({ host: "localhost", // optional - port: 25565, // optional - username: "email@example.com", - password: "12345678", - auth: 'mojang' // optional; by default uses mojang, if using a microsoft account, set to 'microsoft' + port: 25565, // set if you need a port that isn't 25565 + username: 'Bot', // username to join as if auth is `offline`, else a unique identifier for this account. Switch if you want to change accounts + // 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 + // password: '12345678' // set if you want to use password-based auth (may be unreliable). If specified, the `username` must be an email }); -client.on('chat', function(packet) { + +client.on('playerChat', function (ev) { // Listen for chat messages and echo them back. - var jsonMsg = JSON.parse(packet.message); - if(jsonMsg.translate == 'chat.type.announcement' || jsonMsg.translate == 'chat.type.text') { - var username = jsonMsg.with[0].text; - var msg = jsonMsg.with[1]; - if(username === client.username) return; - client.write('chat', {message: msg.text}); - } + const content = ev.formattedMessage + ? JSON.parse(ev.formattedMessage) + : ev.unsignedChat + ? JSON.parse(ev.unsignedContent) + : ev.plainMessage + const jsonMsg = JSON.parse(packet.message) + if (ev.senderName === client.username) return + client.chat(JSON.stringify(content)) }); ``` -If the server is in offline mode, you may leave out the `password` option. -You can also leave out `password` when using a Microsoft account. If provided, password based auth will be attempted first which may fail. *Note:* if using a Microsoft account, your account age must be >= 18 years old. +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. + +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). + +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. + +### Client example joining a Realm + +Example to connect to a Realm that the authenticating account is owner of or has been invited to: + +```js +const mc = require('minecraft-protocol'); +const client = mc.createClient({ + realms: { + pickRealm: (realms) => realms[0] // Function which recieves an array of joined/owned Realms and must return a single Realm. Can be async + }, + auth: 'microsoft' +}) +``` ### Hello World server example +For a more up to date example, see examples/server/server.js. + ```js -var mc = require('minecraft-protocol'); -var server = mc.createServer({ +const mc = require('minecraft-protocol') +const nbt = require('prismarine-nbt') +const server = mc.createServer({ 'online-mode': true, // optional encryption: true, // optional host: '0.0.0.0', // optional port: 25565, // optional - version: '1.16.3' -}); + version: '1.18' +}) const mcData = require('minecraft-data')(server.version) -server.on('login', function(client) { - - let loginPacket = mcData.loginPacket +function chatText (text) { + return mcData.supportFeature('chatPacketsUseNbtComponents') + ? nbt.comp({ text: nbt.string(text) }) + : JSON.stringify({ text }) +} + +server.on('playerJoin', function(client) { + const loginPacket = mcData.loginPacket client.write('login', { + ...loginPacket, + enforceSecureChat: false, entityId: client.id, - isHardcore: false, - gameMode: 0, - previousGameMode: 255, - worldNames: loginPacket.worldNames, - dimensionCodec: loginPacket.dimensionCodec, - dimension: loginPacket.dimension, - worldName: 'minecraft:overworld', hashedSeed: [0, 0], maxPlayers: server.maxPlayers, viewDistance: 10, @@ -127,24 +158,44 @@ server.on('login', function(client) { enableRespawnScreen: true, isDebug: false, isFlat: false - }); + }) + client.write('position', { x: 0, - y: 1.62, + y: 255, z: 0, yaw: 0, pitch: 0, flags: 0x00 - }); - var msg = { + }) + + const message = { translate: 'chat.type.announcement', - "with": [ + with: [ 'Server', 'Hello, world!' ] - }; - client.write("chat", { message: JSON.stringify(msg), position: 0, sender: '0' }); -}); + } + if (mcData.supportFeature('signedChat')) { + client.write('player_chat', { + plainMessage: message, + signedChatContent: '', + unsignedChatContent: chatText(message), + type: mcData.supportFeature('chatTypeIsHolder') ? { chatType: 1 } : 0, + senderUuid: 'd3527a0b-bc03-45d5-a878-2aafdd8c8a43', // random + senderName: JSON.stringify({ text: 'me' }), + senderTeam: undefined, + timestamp: Date.now(), + salt: 0n, + signature: mcData.supportFeature('useChatSessions') ? undefined : Buffer.alloc(0), + previousMessages: [], + filterType: 0, + networkName: JSON.stringify({ text: 'me' }) + }) + } else { + client.write('chat', { message: JSON.stringify({ text: message }), position: 0, sender: 'me' }) + } +}) ``` ## Testing @@ -160,7 +211,7 @@ You can enable some protocol debugging output using `DEBUG` environment variable DEBUG="minecraft-protocol" node [...] ``` -On windows : +On Windows: ``` set DEBUG=minecraft-protocol node your_script.js diff --git a/docs/_sidebar.md b/docs/_sidebar.md index 85de633f6..b30de2a0f 100644 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -2,5 +2,5 @@ - [API](API.md) - [FAQ](FAQ.md) - [History](HISTORY.md) -- [Protocol documentation](http://minecraft-data.prismarine.js.org/?d=protocol) -- [Wiki.vg](https://wiki.vg/Protocol) \ No newline at end of file +- [Protocol Documentation](http://prismarinejs.github.io/minecraft-data?d=protocol) +- [Wiki.vg](https://wiki.vg/Protocol) diff --git a/docs/chat.md b/docs/chat.md new file mode 100644 index 000000000..f8cff1f6c --- /dev/null +++ b/docs/chat.md @@ -0,0 +1,54 @@ +## About chat signing + +Starting in Minecraft 1.19, client messages sent to the server are signed and then broadcasted to other players. +Other clients receiving a signed message can verify that a message was written by a particular player as opposed +to being modified by the server. The way this is achieved is by the client asking Mojang's servers for signing keys, +and the server responding with a private key that can be used to sign messages, and a public key that can be used to +verify the messages. + +When a client connects to the server, it sends its public key to the server, which then sends that to other players +that are on the server. The server also does some checks during the login procedure to authenticate the validity of +the public key, to ensure it came from Mojang. This is achieved by the client sending along a signature from Mojang's +servers in the login step which is the output of concatenating and signing the public key, player UUID and timestamp +with a special Mojang private key specifically for signature validation. The public key used to verify this +signature is public and is stored statically inside node-minecraft-protocol (src/server/constants.js). + +Back to the client, when other players join the server they also get a copy of the players' public key for chat verification. +The clients can then verify that a message came from a client as well as do secondary checks like verifying timestamps. +This feature is designed to allow players to report chat messages from other players to Mojang. When the client reports a +message the contents, the sender UUID, timestamp, and signature are all sent so the Mojang server can verify the message +and send it for moderator review. + +Note: Since the server sends the public key, it's possible that the server can spoof the key and return a fake one, so +only Mojang can truly know if a message came from a client (as it stores its own copy of the clients' chat key pair). + +## 1.19.1 + +Starting with 1.19.1, instead of signing the message itself, a SHA256 hash of the message and last seen messages are +signed instead. In addition, the payload of the hash is prepended with the signature of the previous message sent by the same client, +creating a signed chain of chat messages. See publicly available documentation for more detailed information on this. + +Since chat verification happens on the client-side (as well as server side), all clients need to be kept up to date +on messages from other users. Since not all messages are public (for example, a player may send a signed private message), +the server can send a `chat_header` packet containing the aforementioned SHA256 hash of the message which the client +can generate a signature from, and store as the last signature for that player (maintaining chain integrity). + +In the client, inbound player chat history is now stored in chat logs (in a 1000 length array). This allows players +to search through last seen messages when reporting messages. + +When reporting chat messages, the chained chat functionality and chat history also securely lets Mojang get +authentic message context before and after a reported message. + +## Extra details + +### 1.19.1 + +When a server sends a player a message from another player, the server saves the outbound message and expects +that the client will acknowledge that message, either in a outbound `chat_message` packet's lastSeen field, +or in a `message_acknowledgement` packet. (If the client doesn't seen any chat_message's to the server and +lots of messages pending ACK queue up, a serverbound `message_acknowledgement` packet will be sent to flush the queue.) + +In the server, upon reviewal of the ACK, those messages removed from the servers' pending array. If too many +pending messages pile up, the client will get kicked. + +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). \ No newline at end of file diff --git a/examples/client_channel/client_channel.js b/examples/client_channel/client_channel.js index 5d257e149..b5500b288 100644 --- a/examples/client_channel/client_channel.js +++ b/examples/client_channel/client_channel.js @@ -5,6 +5,14 @@ if (process.argv.length < 4 || process.argv.length > 6) { process.exit(1) } +function getBrandChannelName () { + const mcData = require('minecraft-data')(client.version) + if (mcData.supportFeature('customChannelIdentifier')) { + return 'minecraft:brand' // 1.13+ + } + return 'MC|Brand' +} + const client = mc.createClient({ version: false, host: process.argv[2], @@ -13,10 +21,11 @@ const client = mc.createClient({ password: process.argv[5] }) -client.registerChannel('MC|Brand', ['string', []]) -client.on('MC|Brand', console.log) +client.on('error', console.log) client.on('login', function () { - client.writeChannel('MC|Brand', 'vanilla') + const brandChannel = getBrandChannelName() + client.registerChannel(brandChannel, ['string', []]) + client.on(brandChannel, console.log) + client.writeChannel(brandChannel, 'vanilla') }) -client.on('error', console.log) diff --git a/examples/client_chat/client_chat.js b/examples/client_chat/client_chat.js index 362a9e0e1..5dba86b00 100644 --- a/examples/client_chat/client_chat.js +++ b/examples/client_chat/client_chat.js @@ -1,66 +1,29 @@ -const readline = require('readline') const mc = require('minecraft-protocol') -const states = mc.states - +const readline = require('readline') const rl = readline.createInterface({ input: process.stdin, output: process.stdout, - terminal: false + terminal: false, + prompt: 'Enter a message> ' }) -function printHelp () { - console.log('usage: node client_chat.js []') -} - -if (process.argv.length < 5) { - console.log('Too few arguments!') - printHelp() +const [,, host, port, username] = process.argv +if (!host || !port) { + console.error('Usage: node client_chat.js ') + console.error('Usage (offline mode): node client_chat.js offline') process.exit(1) } -process.argv.forEach(function (val) { - if (val === '-h') { - printHelp() - process.exit(0) - } -}) - -let host = process.argv[2] -let port = parseInt(process.argv[3]) -const user = process.argv[4] -const passwd = process.argv[5] - -let ChatMessage - -if (host.indexOf(':') !== -1) { - port = host.substring(host.indexOf(':') + 1) - host = host.substring(0, host.indexOf(':')) -} - -console.log('connecting to ' + host + ':' + port) -console.log('user: ' + user) - const client = mc.createClient({ - host: host, - port: port, - username: user, - password: passwd -}) - -client.on('kick_disconnect', function (packet) { - console.info('Kicked for ' + packet.reason) - process.exit(1) -}) - -const chats = [] - -client.on('connect', function () { - ChatMessage = require('prismarine-chat')(client.version) - console.info('Successfully connected to ' + host + ':' + port) + host, + port, + username, + auth: username === 'offline' ? 'offline' : 'microsoft' }) +// Boilerplate client.on('disconnect', function (packet) { - console.log('disconnected: ' + packet.reason) + console.log('Disconnected from server : ' + packet.reason) }) client.on('end', function () { @@ -69,19 +32,41 @@ client.on('end', function () { }) client.on('error', function (err) { - console.log('Error occured') + console.log('Error occurred') console.log(err) process.exit(1) }) +client.on('connect', () => { + const ChatMessage = require('prismarine-chat')(client.version) + + console.log('Connected to server') + rl.prompt() + + client.on('playerChat', function ({ senderName, plainMessage, unsignedContent, formattedMessage, verified }) { + let content + + const allowInsecureChat = true + + if (formattedMessage) content = JSON.parse(formattedMessage) + else if (allowInsecureChat && unsignedContent) content = JSON.parse(unsignedContent) + else content = { text: plainMessage } + + const chat = new ChatMessage(content) + console.log(senderName, { trugie: 'Verified:', false: 'UNVERIFIED:' }[verified] || '', chat.toAnsi()) + }) +}) + +// Send the queued messages +const queuedChatMessages = [] client.on('state', function (newState) { - if (newState === states.PLAY) { - chats.forEach(function (chat) { - client.write('chat', { message: chat }) - }) + if (newState === mc.states.PLAY) { + queuedChatMessages.forEach(message => client.chat(message)) + queuedChatMessages.length = 0 } }) +// Listen for messages written to the console, send them to game chat rl.on('line', function (line) { if (line === '') { return @@ -93,14 +78,9 @@ rl.on('line', function (line) { console.info('Forcibly ended client') process.exit(0) } - if (!client.write('chat', { message: line })) { - chats.push(line) + if (!client.chat) { + queuedChatMessages.push(line) + } else { + client.chat(line) } }) - -client.on('chat', function (packet) { - if (!ChatMessage) return // Return if ChatMessage is not loaded yet. - const j = JSON.parse(packet.message) - const chat = new ChatMessage(j) - console.info(chat.toAnsi()) -}) diff --git a/examples/client_custom_auth/client_custom_auth.js b/examples/client_custom_auth/client_custom_auth.js new file mode 100644 index 000000000..263da0938 --- /dev/null +++ b/examples/client_custom_auth/client_custom_auth.js @@ -0,0 +1,41 @@ +'use strict' + +const mc = require('minecraft-protocol') + +const [, , host, port, username, password] = process.argv +if (!username || !password) { + console.log('Usage : node client_custom_auth.js []') + process.exit(1) +} + +const client = mc.createClient({ + host, + port: parseInt(port), + username, + password, + sessionServer: '', // URL to your session server proxy that changes the expected result of mojang's seession server to mcleaks expected. + // For more information: https://github.com/PrismarineJS/node-yggdrasil/blob/master/src/Server.js#L19 + auth: async (client, options) => { + // handle custom authentication your way. + + // client.username = options.username + // options.accessToken = + return options.connect(client) + } +}) + +client.on('connect', function () { + console.info('connected') +}) +client.on('disconnect', function (packet) { + console.log('disconnected: ' + packet.reason) +}) +client.on('chat', function (packet) { + const jsonMsg = JSON.parse(packet.message) + if (jsonMsg.translate === 'chat.type.announcement' || jsonMsg.translate === 'chat.type.text') { + const username = jsonMsg.with[0].text + const msg = jsonMsg.with[1] + if (username === client.username) return + client.write('chat', { message: msg }) + } +}) diff --git a/examples/client_custom_auth/package.json b/examples/client_custom_auth/package.json new file mode 100644 index 000000000..3feec665b --- /dev/null +++ b/examples/client_custom_auth/package.json @@ -0,0 +1,10 @@ +{ + "name": "node-minecraft-protocol-example-client-custom-auth", + "version": "0.0.0", + "description": "A node-minecraft-protocol example", + "main": "client_custom_auth.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "" +} diff --git a/examples/client_custom_channel/client_custom_channel.js b/examples/client_custom_channel/client_custom_channel.js index 6c130dcf0..b12f534fc 100644 --- a/examples/client_custom_channel/client_custom_channel.js +++ b/examples/client_custom_channel/client_custom_channel.js @@ -10,15 +10,15 @@ const client = mc.createClient({ port: parseInt(process.argv[3]), username: process.argv[4] ? process.argv[4] : 'test', password: process.argv[5], - version: '1.10' + version: false }) client.on('login', onlogin) client.on('error', console.log) function onlogin () { - client.registerChannel('CUSTOM|ChannelOne', ['i32', []], true) - client.registerChannel('CUSTOM|ChannelTwo', ['i32', []], true) - client.writeChannel('CUSTOM|ChannelOne', 4) - client.on('CUSTOM|ChannelTwo', console.log) + client.registerChannel('node-minecraft-protocol:custom_channel_one', ['string', []], true) + client.registerChannel('node-minecraft-protocol:custom_channel_two', ['string', []], true) + client.writeChannel('node-minecraft-protocol:custom_channel_one', 'hello from the client') + client.on('node-minecraft-protocol:custom_channel_two', console.log) } diff --git a/examples/client_custom_packets/client_custom_packets.js b/examples/client_custom_packets/client_custom_packets.js index 3c29d6516..998dd9959 100644 --- a/examples/client_custom_packets/client_custom_packets.js +++ b/examples/client_custom_packets/client_custom_packets.js @@ -62,7 +62,7 @@ const client = mc.createClient({ port: parseInt(process.argv[3]), username: process.argv[4] ? process.argv[4] : 'echo', password: process.argv[5], - customPackets: customPackets + customPackets }) client.on('connect', function () { diff --git a/examples/client_microsoft_auth/client_microsoft_auth.js b/examples/client_microsoft_auth/client_microsoft_auth.js index 01eebe898..a96d5915e 100644 --- a/examples/client_microsoft_auth/client_microsoft_auth.js +++ b/examples/client_microsoft_auth/client_microsoft_auth.js @@ -12,7 +12,7 @@ const client = mc.createClient({ host, port: parseInt(port), username: userOrEmail, // your microsoft account email - password: password, // your microsoft account password + password, // your microsoft account password 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. }) diff --git a/examples/client_realms/client_realms.js b/examples/client_realms/client_realms.js new file mode 100644 index 000000000..14985f8e9 --- /dev/null +++ b/examples/client_realms/client_realms.js @@ -0,0 +1,25 @@ +'use strict' + +const mc = require('minecraft-protocol') + +const [,, username, realmName] = process.argv +if (!realmName) { + console.log('Usage : node client_realms.js ') + process.exit(1) +} + +const client = mc.createClient({ + realms: { + // realmId: '1234567', // Connect the client to a Realm using the Realms ID + pickRealm: (realms) => realms.find(e => e.name === realmName) // Connect the client to a Realm using a function that returns a Realm + }, + username, + auth: 'microsoft' // This option must be present and set to 'microsoft' to join a Realm. +}) + +client.on('connect', function () { + console.info('connected') +}) +client.on('disconnect', function (packet) { + console.log('disconnected: ' + packet.reason) +}) diff --git a/examples/client_realms/package.json b/examples/client_realms/package.json new file mode 100644 index 000000000..56fcdf265 --- /dev/null +++ b/examples/client_realms/package.json @@ -0,0 +1,8 @@ +{ + "name": "node-minecraft-protocol-example", + "version": "0.0.0", + "private": true, + "dependencies": { + }, + "description": "A node-minecraft-protocol example" +} diff --git a/examples/ipc/ipc_server.js b/examples/ipc/ipc_server.js new file mode 100644 index 000000000..1eb917537 --- /dev/null +++ b/examples/ipc/ipc_server.js @@ -0,0 +1,51 @@ +/** IPC Connection example + * + * This example shows how to use a IPC connection to communicate with a server or client. + * + * See the node.js documentation about IPC connections here: https://nodejs.org/api/net.html#identifying-paths-for-ipc-connections + */ + +const nmp = require('minecraft-protocol') +const net = require('net') + +const ipcName = 'minecraft-ipc' + +// IPC with node.js works differently on windows and unix systems +let ipcPath +if (process.platform === 'win32') { + ipcPath = `\\\\.\\pipe\\${ipcName}` +} else { + ipcPath = `/tmp/${ipcName}.sock` +} + +const server = nmp.createServer({ + version: '1.18.2', + socketType: 'ipc', + host: ipcPath, // When the optional option socketType is 'ipc' the host becomes the socket path + 'online-mode': false +}) + +server.on('listening', () => { + console.info('Server listening on', server.socketServer.address()) + connectAClient() +}) + +server.on('login', (client) => { + console.info(`New user '${client.username}' logged into the server`) +}) + +function connectAClient () { + const client = nmp.createClient({ + version: '1.18.2', + username: 'ipc_client', + connect: (client) => { + const socket = net.connect(ipcPath, () => { + client.setSocket(socket) + client.emit('connect') + }) + }, + auth: 'offline' + }) + client.on('connect', () => console.info('Client connected to server')) + client.on('end', () => console.info('Client disconnected from server')) +} diff --git a/examples/ipc/package.json b/examples/ipc/package.json new file mode 100644 index 000000000..cb8cd4913 --- /dev/null +++ b/examples/ipc/package.json @@ -0,0 +1,8 @@ +{ + "name": "node-minecraft-protocol-example", + "version": "0.0.0", + "private": true, + "dependencies": { + }, + "description": "A node-minecraft-protocol example" +} \ No newline at end of file diff --git a/examples/proxy/proxy.js b/examples/proxy/proxy.js index 499c4a5fa..c6835559e 100644 --- a/examples/proxy/proxy.js +++ b/examples/proxy/proxy.js @@ -73,7 +73,7 @@ const srv = mc.createServer({ 'online-mode': false, port: 25566, keepAlive: false, - version: version + version }) srv.on('login', function (client) { const addr = client.socket.remoteAddress @@ -92,11 +92,11 @@ srv.on('login', function (client) { if (!endedTargetClient) { targetClient.end('Error') } }) const targetClient = mc.createClient({ - host: host, - port: port, + host, + port, username: client.username, keepAlive: false, - version: version + version }) client.on('packet', function (data, meta) { if (targetClient.state === states.PLAY && meta.state === states.PLAY) { diff --git a/examples/server/server.js b/examples/server/server.js index 59a6bc864..f503bb4cb 100644 --- a/examples/server/server.js +++ b/examples/server/server.js @@ -1,4 +1,5 @@ const mc = require('minecraft-protocol') +const nbt = require('prismarine-nbt') const options = { motd: 'Vox Industries', @@ -11,7 +12,16 @@ const server = mc.createServer(options) const mcData = require('minecraft-data')(server.version) const loginPacket = mcData.loginPacket -server.on('login', function (client) { +// Global chat index counter for 1.21.5+ +let nextChatIndex = 1 + +function chatText (text) { + return mcData.supportFeature('chatPacketsUseNbtComponents') + ? nbt.comp({ text: nbt.string(text) }) + : JSON.stringify({ text }) +} + +server.on('playerJoin', function (client) { broadcast(client.username + ' joined the game.') const addr = client.socket.remoteAddress + ':' + client.socket.remotePort console.log(client.username + ' connected', '(' + addr + ')') @@ -23,14 +33,12 @@ server.on('login', function (client) { // send init data so client will start rendering world client.write('login', { + ...loginPacket, + enforceSecureChat: false, entityId: client.id, isHardcore: false, gameMode: 0, previousGameMode: 1, - worldNames: loginPacket.worldNames, - dimensionCodec: loginPacket.dimensionCodec, - dimension: loginPacket.dimension, - worldName: 'minecraft:overworld', hashedSeed: [0, 0], maxPlayers: server.maxPlayers, viewDistance: 10, @@ -48,11 +56,13 @@ server.on('login', function (client) { flags: 0x00 }) - client.on('chat', function (data) { + function handleChat (data) { const message = '<' + client.username + '>' + ' ' + data.message broadcast(message, null, client.username) console.log(message) - }) + } + client.on('chat', handleChat) // pre-1.19 + client.on('chat_message', handleChat) // post 1.19 }) server.on('error', function (error) { @@ -63,27 +73,29 @@ server.on('listening', function () { console.log('Server listening on port', server.socketServer.address().port) }) -function broadcast (message, exclude, username) { - let client - const translate = username ? 'chat.type.announcement' : 'chat.type.text' - username = username || 'Server' - for (const clientId in server.clients) { - if (server.clients[clientId] === undefined) continue - - client = server.clients[clientId] - if (client !== exclude) { - const msg = { - translate: translate, - with: [ - username, - message - ] - } - client.write('chat', { - message: JSON.stringify(msg), - position: 0, - sender: '0' - }) - } +function sendBroadcastMessage (server, clients, message, sender) { + if (mcData.supportFeature('signedChat')) { + server.writeToClients(clients, 'player_chat', { + globalIndex: nextChatIndex++, + plainMessage: message, + signedChatContent: '', + unsignedChatContent: chatText(message), + type: mcData.supportFeature('chatTypeIsHolder') ? { chatType: 1 } : 0, + senderUuid: 'd3527a0b-bc03-45d5-a878-2aafdd8c8a43', // random + senderName: JSON.stringify({ text: sender }), + senderTeam: undefined, + timestamp: Date.now(), + salt: 0n, + signature: mcData.supportFeature('useChatSessions') ? undefined : Buffer.alloc(0), + previousMessages: [], + filterType: 0, + networkName: JSON.stringify({ text: sender }) + }) + } else { + server.writeToClients(clients, 'chat', { message: JSON.stringify({ text: message }), position: 0, sender: sender || '0' }) } } + +function broadcast (message, exclude, username) { + sendBroadcastMessage(server, Object.values(server.clients).filter(client => client !== exclude), message) +} diff --git a/examples/server_channel/server_channel.js b/examples/server_channel/server_channel.js index d39eea5f8..4dc4ff1d9 100644 --- a/examples/server_channel/server_channel.js +++ b/examples/server_channel/server_channel.js @@ -3,25 +3,22 @@ const mc = require('minecraft-protocol') const server = mc.createServer({ 'online-mode': false, // optional encryption: false, // optional - host: '0.0.0.0', // optional - port: 25565, // optional - version: '1.16' + version: '1.18.2' }) const mcData = require('minecraft-data')(server.version) const loginPacket = mcData.loginPacket -server.on('login', function (client) { - client.registerChannel('MC|Brand', ['string', []]) - client.on('MC|Brand', console.log) +server.on('playerJoin', function (client) { + client.registerChannel('minecraft:brand', ['string', []]) + client.on('minecraft:brand', console.log) client.write('login', { + ...loginPacket, + enforceSecureChat: false, entityId: client.id, isHardcore: false, gameMode: 0, previousGameMode: 1, - worldNames: loginPacket.worldNames, - dimensionCodec: loginPacket.dimensionCodec, - dimension: loginPacket.dimension, worldName: 'minecraft:overworld', hashedSeed: [0, 0], maxPlayers: server.maxPlayers, @@ -39,5 +36,5 @@ server.on('login', function (client) { pitch: 0, flags: 0x00 }) - client.writeChannel('MC|Brand', 'vanilla') + client.writeChannel('minecraft:brand', 'vanilla') }) diff --git a/examples/server_custom_channel/server_custom_channel.js b/examples/server_custom_channel/server_custom_channel.js index f7f00b984..cc2c3b4ae 100644 --- a/examples/server_custom_channel/server_custom_channel.js +++ b/examples/server_custom_channel/server_custom_channel.js @@ -3,22 +3,19 @@ const mc = require('minecraft-protocol') const server = mc.createServer({ 'online-mode': false, // optional encryption: false, // optional - host: '0.0.0.0', // optional - port: 25565, // optional - version: '1.16' + version: '1.18.2' }) const mcData = require('minecraft-data')(server.version) const loginPacket = mcData.loginPacket -server.on('login', function (client) { +server.on('playerJoin', function (client) { client.write('login', { + ...loginPacket, + enforceSecureChat: false, entityId: client.id, isHardcore: false, gameMode: 0, previousGameMode: 1, - worldNames: loginPacket.worldNames, - dimensionCodec: loginPacket.dimensionCodec, - dimension: loginPacket.dimension, worldName: 'minecraft:overworld', hashedSeed: [0, 0], maxPlayers: server.maxPlayers, @@ -28,8 +25,8 @@ server.on('login', function (client) { isDebug: false, isFlat: false }) - client.registerChannel('CUSTOM|ChannelOne', ['i32', []], true) - client.registerChannel('CUSTOM|ChannelTwo', ['i32', []], true) + client.registerChannel('node-minecraft-protocol:custom_channel_one', ['string', []], true) + client.registerChannel('node-minecraft-protocol:custom_channel_two', ['string', []], true) client.write('position', { x: 0, y: 1.62, @@ -38,6 +35,6 @@ server.on('login', function (client) { pitch: 0, flags: 0x00 }) - client.writeChannel('CUSTOM|ChannelTwo', 10) - client.on('CUSTOM|ChannelOne', console.log) + client.writeChannel('node-minecraft-protocol:custom_channel_two', 'hello from the server') + client.on('node-minecraft-protocol:custom_channel_one', console.log) }) diff --git a/examples/server_helloworld/server_helloworld.js b/examples/server_helloworld/server_helloworld.js index d752a3010..d213e6ec0 100644 --- a/examples/server_helloworld/server_helloworld.js +++ b/examples/server_helloworld/server_helloworld.js @@ -8,8 +8,18 @@ const options = { const server = mc.createServer(options) const mcData = require('minecraft-data')(server.version) const loginPacket = mcData.loginPacket +const nbt = require('prismarine-nbt') -server.on('login', function (client) { +// Global chat index counter for 1.21.5+ +let nextChatIndex = 1 + +function chatText (text) { + return mcData.supportFeature('chatPacketsUseNbtComponents') + ? nbt.comp({ text: nbt.string(text) }) + : JSON.stringify({ text }) +} + +server.on('playerJoin', function (client) { const addr = client.socket.remoteAddress console.log('Incoming connection', '(' + addr + ')') @@ -49,14 +59,33 @@ server.on('login', function (client) { flags: 0x00 }) - const msg = { + const message = { translate: 'chat.type.announcement', with: [ 'Server', 'Hello, world!' ] } - client.write('chat', { message: JSON.stringify(msg), position: 0, sender: '0' }) + if (mcData.supportFeature('signedChat')) { + client.write('player_chat', { + globalIndex: nextChatIndex++, + plainMessage: message, + signedChatContent: '', + unsignedChatContent: chatText(message), + type: mcData.supportFeature('chatTypeIsHolder') ? { chatType: 1 } : 0, + senderUuid: 'd3527a0b-bc03-45d5-a878-2aafdd8c8a43', // random + senderName: JSON.stringify({ text: 'me' }), + senderTeam: undefined, + timestamp: Date.now(), + salt: 0n, + signature: mcData.supportFeature('useChatSessions') ? undefined : Buffer.alloc(0), + previousMessages: [], + filterType: 0, + networkName: JSON.stringify({ text: 'me' }) + }) + } else { + client.write('chat', { message: JSON.stringify({ text: message }), position: 0, sender: 'me' }) + } }) server.on('error', function (error) { diff --git a/examples/server_ping/ping.js b/examples/server_ping/ping.js index df68e89f0..d0973fcd1 100644 --- a/examples/server_ping/ping.js +++ b/examples/server_ping/ping.js @@ -20,7 +20,7 @@ if (!process.argv[2].includes(':')) { // Spliting ip and port if available. port = parseInt(port) } -protocol.ping({ host: host, port: port }, (err, pingResults) => { // Pinging server and getting result +protocol.ping({ host, port }, (err, pingResults) => { // Pinging server and getting result if (err) throw err console.log(`${removeColorsFromString(JSON.stringify(pingResults.description.text))}`) // Printing motd to console // Printing some infos to console diff --git a/examples/server_world/mc.js b/examples/server_world/mc.js index 7acf27ca0..eb0975ee9 100644 --- a/examples/server_world/mc.js +++ b/examples/server_world/mc.js @@ -22,15 +22,13 @@ for (let x = 0; x < 16; x++) { } } -server.on('login', function (client) { +server.on('playerJoin', function (client) { client.write('login', { + ...loginPacket, entityId: client.id, isHardcore: false, gameMode: 0, previousGameMode: 1, - worldNames: loginPacket.worldNames, - dimensionCodec: loginPacket.dimensionCodec, - dimension: loginPacket.dimension, worldName: 'minecraft:overworld', hashedSeed: [0, 0], maxPlayers: server.maxPlayers, diff --git a/package.json b/package.json index 81bad3984..63e1c75d5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "minecraft-protocol", - "version": "1.30.0", + "version": "1.62.0", "description": "Parse and serialize minecraft packets, plus authentication and encryption.", "main": "src/index.js", "types": "src/index.d.ts", @@ -9,8 +9,8 @@ "url": "git://github.com/PrismarineJS/node-minecraft-protocol.git" }, "scripts": { - "test": "mocha --recursive --reporter spec --exit --exclude \"non-par-test.js\"", - "test-non-par": "mocha --recursive --reporter spec --exit \"test/non-par-test.js\"", + "test": "npm run mochaTest", + "mochaTest": "mocha --recursive --reporter spec --exit", "lint": "standard", "fix": "standard --fix", "pretest": "npm run lint", @@ -29,35 +29,39 @@ "author": "Andrew Kelley", "license": "BSD-3-Clause", "engines": { - "node": ">=6" + "node": ">=22" }, "browser": "src/browser.js", "devDependencies": { - "@types/node": "^17.0.4", + "@types/node": "^24.0.4", "espower-loader": "^1.0.0", "intelli-espower-loader": "^1.0.0", "minecraft-packets": "^1.1.5", "minecraft-protocol": "file:.", "minecraft-wrap": "^1.2.3", - "mocha": "^9.0.0", + "mocha": "^11.0.1", "power-assert": "^1.0.0", - "standard": "^16.0.1" + "standard": "^17.0.0", + "prismarine-registry": "^1.8.0" }, "dependencies": { + "@types/node-rsa": "^1.1.4", + "@types/readable-stream": "^4.0.0", "aes-js": "^3.1.2", "buffer-equal": "^1.0.0", "debug": "^4.3.2", "endian-toggle": "^0.0.0", - "lodash.get": "^4.1.2", "lodash.merge": "^4.3.0", - "minecraft-data": "^2.98.0", + "minecraft-data": "^3.78.0", "minecraft-folder-path": "^1.2.0", "node-fetch": "^2.6.1", "node-rsa": "^0.4.2", - "prismarine-auth": "^1.1.0", - "prismarine-nbt": "^2.0.0", - "protodef": "^1.8.0", - "readable-stream": "^3.0.6", + "prismarine-auth": "^2.2.0", + "prismarine-chat": "^1.10.0", + "prismarine-nbt": "^2.5.0", + "prismarine-realms": "^1.2.0", + "protodef": "^1.17.0", + "readable-stream": "^4.1.0", "uuid-1345": "^1.0.1", "yggdrasil": "^1.4.0" } diff --git a/src/browser.js b/src/browser.js index 4c01b24fe..d51f98bb1 100644 --- a/src/browser.js +++ b/src/browser.js @@ -5,8 +5,8 @@ const Server = require('./server') const serializer = require('./transforms/serializer') module.exports = { - Client: Client, - Server: Server, + Client, + Server, states: require('./states'), createSerializer: serializer.createSerializer, createDeserializer: serializer.createDeserializer, diff --git a/src/client.js b/src/client.js index 03e2c9bf1..e369e77d0 100644 --- a/src/client.js +++ b/src/client.js @@ -1,10 +1,10 @@ 'use strict' - const EventEmitter = require('events').EventEmitter -const debug = require('debug')('minecraft-protocol') const compression = require('./transforms/compression') const framing = require('./transforms/framing') const states = require('./states') +const debug = require('debug')('minecraft-protocol') +const debugSkip = process.env.DEBUG_SKIP?.split(',') ?? [] const createSerializer = require('./transforms/serializer').createSerializer const createDeserializer = require('./transforms/serializer').createDeserializer @@ -30,8 +30,10 @@ class Client extends EventEmitter { this.latency = 0 this.hideErrors = hideErrors this.closeTimer = null - + const mcData = require('minecraft-data')(version) + this._supportFeature = mcData.supportFeature this.state = states.HANDSHAKING + this._hasBundlePacket = mcData.supportFeature('hasBundlePacket') } get state () { @@ -39,11 +41,11 @@ class Client extends EventEmitter { } setSerializer (state) { - this.serializer = createSerializer({ isServer: this.isServer, version: this.version, state: state, customPackets: this.customPackets }) + this.serializer = createSerializer({ isServer: this.isServer, version: this.version, state, customPackets: this.customPackets }) this.deserializer = createDeserializer({ isServer: this.isServer, version: this.version, - state: state, + state, packetsToParse: this.packetsToParse, customPackets: this.customPackets, @@ -66,31 +68,51 @@ class Client extends EventEmitter { }) this.deserializer.on('error', (e) => { - let parts + let parts = [] if (e.field) { parts = e.field.split('.') parts.shift() - } else { parts = [] } + } const deserializerDirection = this.isServer ? 'toServer' : 'toClient' e.field = [this.protocolState, deserializerDirection].concat(parts).join('.') - e.message = `Deserialization error for ${e.field} : ${e.message}` + 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}` if (!this.compressor) { this.splitter.pipe(this.deserializer) } else { this.decompressor.pipe(this.deserializer) } this.emit('error', e) }) - + this._mcBundle = [] + const emitPacket = (parsed) => { + this.emit('packet', parsed.data, parsed.metadata, parsed.buffer, parsed.fullBuffer) + this.emit(parsed.metadata.name, parsed.data, parsed.metadata) + this.emit('raw.' + parsed.metadata.name, parsed.buffer, parsed.metadata) + this.emit('raw', parsed.buffer, parsed.metadata) + } this.deserializer.on('data', (parsed) => { parsed.metadata.name = parsed.data.name parsed.data = parsed.data.params parsed.metadata.state = state - debug('read packet ' + state + '.' + parsed.metadata.name) - if (debug.enabled) { + if (debug.enabled && !debugSkip.includes(parsed.metadata.name)) { + debug('read packet ' + state + '.' + parsed.metadata.name) const s = JSON.stringify(parsed.data, null, 2) debug(s && s.length > 10000 ? parsed.data : s) } - this.emit('packet', parsed.data, parsed.metadata, parsed.buffer, parsed.fullBuffer) - this.emit(parsed.metadata.name, parsed.data, parsed.metadata) - this.emit('raw.' + parsed.metadata.name, parsed.buffer, parsed.metadata) - this.emit('raw', parsed.buffer, parsed.metadata) + if (this._hasBundlePacket && parsed.metadata.name === 'bundle_delimiter') { + if (this._mcBundle.length) { // End bundle + this._mcBundle.forEach(emitPacket) + emitPacket(parsed) + this._mcBundle = [] + } else { // Start bundle + this._mcBundle.push(parsed) + } + } else if (this._mcBundle.length) { + this._mcBundle.push(parsed) + if (this._mcBundle.length > 32) { + this._mcBundle.forEach(emitPacket) + this._mcBundle = [] + this._hasBundlePacket = false + } + } else { + emitPacket(parsed) + } }) } @@ -117,6 +139,7 @@ class Client extends EventEmitter { this.splitter.pipe(this.deserializer) } else { this.serializer.pipe(this.compressor) + if (globalThis.debugNMP) this.decompressor.on('data', (data) => { console.log('DES>', data.toString('hex')) }) this.decompressor.pipe(this.deserializer) } @@ -216,11 +239,19 @@ class Client extends EventEmitter { write (name, params) { if (!this.serializer.writable) { return } - debug('writing packet ' + this.state + '.' + name) - debug(params) + if (debug.enabled && !debugSkip.includes(name)) { + debug('writing packet ' + this.state + '.' + name) + debug(params) + } this.serializer.write({ name, params }) } + writeBundle (packets) { + if (this._hasBundlePacket) this.write('bundle_delimiter', {}) + for (const [name, params] of packets) this.write(name, params) + if (this._hasBundlePacket) this.write('bundle_delimiter', {}) + } + writeRaw (buffer) { const stream = this.compressor === null ? this.framer : this.compressor if (!stream.writable) { return } diff --git a/src/client/autoVersion.js b/src/client/autoVersion.js index c437ecf3a..3fe155267 100644 --- a/src/client/autoVersion.js +++ b/src/client/autoVersion.js @@ -29,7 +29,7 @@ module.exports = function (client, options) { .sort(function (a, b) { return b.version - a.version }) const versions = (minecraftData.postNettyVersionsByProtocolVersion.pc[protocolVersion] || []).concat(guessFromName) if (versions.length === 0) { - client.emit('error', new Error(`unsupported/unknown protocol version: ${protocolVersion}, update minecraft-data`)) + client.emit('error', new Error(`Unsupported protocol version '${protocolVersion}'; try updating your packages with 'npm update'`)) } const minecraftVersion = versions[0].minecraftVersion diff --git a/src/client/chat.js b/src/client/chat.js new file mode 100644 index 000000000..002187099 --- /dev/null +++ b/src/client/chat.js @@ -0,0 +1,592 @@ +const crypto = require('crypto') +const { computeChatChecksum } = require('../datatypes/checksums') +const concat = require('../transforms/binaryStream').concat +const { processNbtMessage } = require('prismarine-chat') +const messageExpireTime = 420000 // 7 minutes (ms) + +function isFormatted (message) { + // This should match the ChatComponent.isDecorated function from Vanilla + try { + const comp = JSON.parse(message) + for (const key in comp) { + if (key !== 'text') return true + } + if (comp.text && comp.text !== message) return true + return false + } catch { + return false + } +} + +module.exports = function (client, options) { + const mcData = require('minecraft-data')(client.version) + client._players = {} + client._lastChatSignature = null + client._lastRejectedMessage = null + + // This stores the last n (5 or 20) messages that the player has seen, from unique players + if (mcData.supportFeature('chainedChatWithHashing')) client._lastSeenMessages = new LastSeenMessages() + else client._lastSeenMessages = new LastSeenMessagesWithInvalidation() + // 1.20.3+ serializes chat components in either NBT or JSON. If the chat is sent as NBT, then the structure read will differ + // from the normal JSON structure, so it needs to be normalized. prismarine-chat processNbtMessage will do that by default + // on a fromNotch call. Since we don't call fromNotch here (done in mineflayer), we manually call processNbtMessage + const processMessage = (msg) => mcData.supportFeature('chatPacketsUseNbtComponents') ? processNbtMessage(msg) : msg + + // This stores the last 128 inbound (signed) messages for 1.19.3 chat validation + client._signatureCache = new SignatureCache() + + // This stores last 1024 inbound messages for report lookup + client._lastChatHistory = new class extends Array { + capacity = 1024 + push (e) { + super.push(e) + if (this.length > this.capacity) { + this.shift() + } + } + }() + + function updateAndValidateSession (uuid, message, currentSignature, index, previousMessages, salt, timestamp) { + const player = client._players[uuid] + + if (player && player.hasChainIntegrity) { + if (!player.lastSignature || player.lastSignature.equals(currentSignature) || index > player.sessionIndex) { + player.lastSignature = currentSignature + player.sessionIndex = index + } else { + player.hasChainIntegrity = false + } + + if (player.hasChainIntegrity) { + const length = Buffer.byteLength(message, 'utf8') + const validBuffers = previousMessages + .filter(msg => msg.signature || client._signatureCache[msg.id]) // Filter out invalid messages + .map(msg => msg.signature || client._signatureCache[msg.id]) + .filter(buf => Buffer.isBuffer(buf)) + + const acknowledgements = validBuffers.length > 0 + ? ['i32', validBuffers.length, 'buffer', Buffer.concat(validBuffers)] + : ['i32', 0] + const signable = concat('i32', 1, 'UUID', uuid, 'UUID', player.sessionUuid, 'i32', index, 'i64', salt, 'i64', timestamp / 1000n, 'i32', length, 'pstring', message, ...acknowledgements) + + player.hasChainIntegrity = crypto.verify('RSA-SHA256', signable, player.publicKey, currentSignature) + } + + return player.hasChainIntegrity + } + + return false + } + + function updateAndValidateChat (uuid, previousSignature, currentSignature, payload) { + // Get the player information + const player = client._players[uuid] + if (player && player.hasChainIntegrity) { + if (!player.lastSignature) { + // First time client is handling a chat message from this player, allow + player.lastSignature = currentSignature + } else if (player.lastSignature.equals(previousSignature)) { + player.lastSignature = currentSignature + } else { + // Not valid, client can no longer authenticate messages until player quits and reconnects + player.hasChainIntegrity = false + } + + if (player.hasChainIntegrity) { + const verifier = crypto.createVerify('RSA-SHA256') + if (previousSignature) verifier.update(previousSignature) + verifier.update(concat('UUID', uuid)) + verifier.update(payload) + player.hasChainIntegrity = verifier.verify(player.publicKey, currentSignature) + } + + return player.hasChainIntegrity + } + + return false + } + + client.on('player_remove', (packet) => { + for (const player of packet.players) { + delete client._players[player.UUID] + } + }) + + client.on('player_info', (packet) => { + for (const player of packet.data) { + if (player.chatSession) { + client._players[player.uuid] = { + publicKey: crypto.createPublicKey({ key: player.chatSession.publicKey.keyBytes, format: 'der', type: 'spki' }), + publicKeyDER: player.chatSession.publicKey.keyBytes, + sessionUuid: player.chatSession.uuid + } + client._players[player.uuid].sessionIndex = true + client._players[player.uuid].hasChainIntegrity = true + } + + if (player.crypto) { + client._players[player.uuid] = { + publicKey: crypto.createPublicKey({ key: player.crypto.publicKey, format: 'der', type: 'spki' }), + publicKeyDER: player.crypto.publicKey, + signature: player.crypto.signature, + displayName: player.displayName || player.name + } + client._players[player.uuid].hasChainIntegrity = true + } + + if (packet.action === 'remove_player') { // Only 1.8-1.9 + delete client._players[player.uuid] + } + } + }) + + client.on('profileless_chat', (packet) => { + // Profileless chat is parsed as an unsigned player chat message but logged as a system message + client.emit('playerChat', { + formattedMessage: processMessage(packet.message), + type: packet.type, + senderName: processMessage(packet.name), + targetName: processMessage(packet.target), + verified: false + }) + + client._lastChatHistory.push({ + type: 2, // System message + message: { + decorated: packet.content // This should actually decorate the message with the sender and target name using the chat type + }, + timestamp: Date.now() + }) + }) + + client.on('system_chat', (packet) => { + client.emit('systemChat', { + positionId: packet.isActionBar ? 2 : 1, + formattedMessage: processMessage(packet.content) + }) + + client._lastChatHistory.push({ + type: 2, // System message + message: { + decorated: packet.content + }, + timestamp: Date.now() + }) + }) + + client.on('message_header', (packet) => { // [1.19.2] + updateAndValidateChat(packet.senderUuid, packet.previousSignature, packet.signature, packet.messageHash) + + client._lastChatHistory.push({ + type: 1, // Message header + previousSignature: packet.previousSignature, + signature: packet.signature, + messageHash: packet.messageHash + }) + }) + + client.on('hide_message', (packet) => { + if (mcData.supportFeature('useChatSessions')) { + const signature = packet.signature || client._signatureCache[packet.id] + if (signature) client._lastSeenMessages = client._lastSeenMessages.map(ack => (ack.signature === signature && ack.pending) ? null : ack) + } + }) + + client.on('player_chat', (packet) => { + if (mcData.supportFeature('useChatSessions')) { + const tsDelta = BigInt(Date.now()) - packet.timestamp + const expired = !packet.timestamp || tsDelta > messageExpireTime || tsDelta < 0 + const verified = !packet.unsignedChatContent && updateAndValidateSession(packet.senderUuid, packet.plainMessage, packet.signature, packet.index, packet.previousMessages, packet.salt, packet.timestamp) && !expired + if (verified) client._signatureCache.push(packet.signature) + client.emit('playerChat', { + globalIndex: packet.globalIndex, + plainMessage: packet.plainMessage, + unsignedContent: processMessage(packet.unsignedChatContent), + type: packet.type, + sender: packet.senderUuid, + senderName: processMessage(packet.networkName), + targetName: processMessage(packet.networkTargetName), + verified + }) + + client._lastChatHistory.push({ + type: 0, // Player message + signature: packet.signature, + message: { + plain: packet.plainMessage + }, + session: { + index: packet.index, + uuid: client._players[packet.senderUuid]?.sessionUuid + }, + timestamp: packet.timestamp, + salt: packet.salt, + lastSeen: packet.previousMessages.map(msg => msg.signature || client._signatureCache[msg.id]) + }) + + if (client._lastSeenMessages.push(packet.signature) && client._lastSeenMessages.pending > 64) { + client.write('message_acknowledgement', { + count: client._lastSeenMessages.pending + }) + client._lastSeenMessages.pending = 0 + } + return + } + + if (mcData.supportFeature('chainedChatWithHashing')) { + const hash = crypto.createHash('sha256') + hash.update(concat('i64', packet.salt, 'i64', packet.timestamp / 1000n, 'pstring', packet.plainMessage, 'i8', 70)) + if (packet.formattedMessage) hash.update(packet.formattedMessage) + for (const previousMessage of packet.previousMessages) { + hash.update(concat('i8', 70, 'UUID', previousMessage.messageSender)) + hash.update(previousMessage.messageSignature) + } + + // Chain integrity remains even if message is considered unverified due to expiry + const tsDelta = BigInt(Date.now()) - packet.timestamp + const expired = !packet.timestamp || tsDelta > messageExpireTime || tsDelta < 0 + const verified = !packet.unsignedChatContent && updateAndValidateChat(packet.senderUuid, packet.previousSignature, packet.signature, hash.digest()) && !expired + client.emit('playerChat', { + plainMessage: packet.plainMessage, + unsignedContent: packet.unsignedChatContent, + formattedMessage: packet.formattedMessage, + type: packet.type, + sender: packet.senderUuid, + senderName: packet.networkName, + targetName: packet.networkTargetName, + verified + }) + + // We still accept a message (by pushing to seenMessages) even if the chain is broken. A vanilla client + // will reject a message if the client sets secure chat to be required and the message from the server + // isn't signed, or the client has blocked the sender. + // client1.19.1/client/net/minecraft/client/multiplayer/ClientPacketListener.java#L768 + client._lastChatHistory.push({ + type: 0, // Player message + previousSignature: packet.previousSignature, + signature: packet.signature, + message: { + plain: packet.plainMessage, + decorated: packet.formattedMessage + }, + messageHash: packet.messageHash, + timestamp: packet.timestamp, + salt: packet.salt, + lastSeen: packet.previousMessages + }) + + if (client._lastSeenMessages.push({ sender: packet.senderUuid, signature: packet.signature }) && client._lastSeenMessages.pending++ > 64) { + client.write('message_acknowledgement', { + previousMessages: client._lastSeenMessages.map((e) => ({ + messageSender: e.sender, + messageSignature: e.signature + })), + lastRejectedMessage: client._lastRejectedMessage + }) + client._lastSeenMessages.pending = 0 + } + + return + } + + const pubKey = client._players[packet.senderUuid]?.publicKey + client.emit('playerChat', { + formattedMessage: packet.signedChatContent || packet.unsignedChatContent, + type: packet.type, + sender: packet.senderUuid, + senderName: packet.senderName, + senderTeam: packet.senderTeam, + verified: (pubKey && !packet.unsignedChatContent) ? client.verifyMessage(pubKey, packet) : false + }) + }) + + const sliceIndexForMessage = {} + client.on('declare_commands', (packet) => { + const nodes = packet.nodes + for (const commandNode of nodes[0].children) { + const node = nodes[commandNode] + const commandName = node.extraNodeData.name + function visit (node, depth = 0) { + const name = node.extraNodeData.name + if (node.extraNodeData.parser === 'minecraft:message') { + sliceIndexForMessage[commandName] = [name, depth] + } + for (const child of node.children) { + visit(nodes[child], depth + 1) + } + } + visit(node, 0) + } + }) + + function signaturesForCommand (string, ts, salt, preview, acknowledgements) { + const signatures = [] + const slices = string.split(' ') + if (sliceIndexForMessage[slices[0]]) { + const [fieldName, sliceIndex] = sliceIndexForMessage[slices[0]] + const sliced = slices.slice(sliceIndex) + if (sliced.length > 0) { + const signable = sliced.join(' ') + signatures.push({ argumentName: fieldName, signature: client.signMessage(signable, ts, salt, preview, acknowledgements) }) + } + } + return signatures + } + + // Chat Sending + let pendingChatRequest + let lastPreviewRequestId = 0 + + function getAcknowledgements () { + let acc = 0 + const acknowledgements = [] + + for (let i = 0; i < client._lastSeenMessages.capacity; i++) { + const idx = (client._lastSeenMessages.offset + i) % 20 + const message = client._lastSeenMessages[idx] + if (message) { + acc |= 1 << i + acknowledgements.push(message.signature) + message.pending = false + } + } + + const bitset = Buffer.allocUnsafe(3) + bitset[0] = acc & 0xFF + bitset[1] = (acc >> 8) & 0xFF + bitset[2] = (acc >> 16) & 0xFF + + return { + acknowledgements, + acknowledged: bitset + } + } + + client._signedChat = (message, options = {}) => { + options.timestamp = options.timestamp || BigInt(Date.now()) + options.salt = options.salt || 1n + + if (message.startsWith('/')) { + const command = message.slice(1) + if (mcData.supportFeature('useChatSessions')) { // 1.19.3+ + const { acknowledged, acknowledgements } = getAcknowledgements() + const canSign = client.profileKeys && client._session + const chatPacket = { + command, + timestamp: options.timestamp, + salt: options.salt, + argumentSignatures: canSign ? signaturesForCommand(command, options.timestamp, options.salt, options.preview, acknowledgements) : [], + messageCount: client._lastSeenMessages.pending, + checksum: computeChatChecksum(client._lastSeenMessages), // 1.21.5+ + acknowledged + } + client.write((mcData.supportFeature('seperateSignedChatCommandPacket') && canSign) ? 'chat_command_signed' : 'chat_command', chatPacket) + client._lastSeenMessages.pending = 0 + } else { + client.write('chat_command', { + command, + timestamp: options.timestamp, + salt: options.salt, + argumentSignatures: client.profileKeys ? signaturesForCommand(command, options.timestamp, options.salt) : [], + signedPreview: options.didPreview, + previousMessages: client._lastSeenMessages.map((e) => ({ + messageSender: e.sender, + messageSignature: e.signature + })), + checksum: computeChatChecksum(client._lastSeenMessages), + lastRejectedMessage: client._lastRejectedMessage + }) + } + + return + } + + if (mcData.supportFeature('useChatSessions')) { + const { acknowledgements, acknowledged } = getAcknowledgements() + client.write('chat_message', { + message, + timestamp: options.timestamp, + salt: options.salt, + signature: (client.profileKeys && client._session) ? client.signMessage(message, options.timestamp, options.salt, undefined, acknowledgements) : undefined, + offset: client._lastSeenMessages.pending, + checksum: computeChatChecksum(client._lastSeenMessages), // 1.21.5+ + acknowledged + }) + client._lastSeenMessages.pending = 0 + + return + } + + if (options.skipPreview || !client.serverFeatures.chatPreview) { + client.write('chat_message', { + message, + timestamp: options.timestamp, + salt: options.salt, + signature: client.profileKeys ? client.signMessage(message, options.timestamp, options.salt, options.preview) : Buffer.alloc(0), + signedPreview: options.didPreview, + previousMessages: client._lastSeenMessages.map((e) => ({ + messageSender: e.sender, + messageSignature: e.signature + })), + lastRejectedMessage: client._lastRejectedMessage, + checksum: computeChatChecksum(client._lastSeenMessages) // 1.21.5+ + }) + client._lastSeenMessages.pending = 0 + } else if (client.serverFeatures.chatPreview) { + client.write('chat_preview', { + query: lastPreviewRequestId, + message + }) + pendingChatRequest = { id: lastPreviewRequestId, message, options } + lastPreviewRequestId++ + } + } + + client.on('chat_preview', (packet) => { + if (pendingChatRequest && pendingChatRequest.id === packet.queryId) { + client._signedChat(pendingChatRequest.message, { ...pendingChatRequest.options, skipPreview: true, didPreview: true, preview: isFormatted(packet.message) ? packet.message : undefined }) + pendingChatRequest = null + } + }) + + // Signing methods + client.signMessage = (message, timestamp, salt = 0, preview, acknowledgements) => { + if (!client.profileKeys) throw Error("Can't sign message without profile keys, please set valid auth mode") + + if (mcData.supportFeature('useChatSessions')) { + if (!client._session.uuid) throw Error("Chat session not initialized. Can't send chat") + + const length = Buffer.byteLength(message, 'utf8') + const previousMessages = acknowledgements.length > 0 ? ['i32', acknowledgements.length, 'buffer', Buffer.concat(acknowledgements)] : ['i32', 0] + + const signable = concat('i32', 1, 'UUID', client.uuid, 'UUID', client._session.uuid, 'i32', client._session.index++, 'i64', salt, 'i64', timestamp / 1000n, 'i32', length, 'pstring', message, ...previousMessages) + return crypto.sign('RSA-SHA256', signable, client.profileKeys.private) + } else if (mcData.supportFeature('chainedChatWithHashing')) { + // 1.19.2 + const signer = crypto.createSign('RSA-SHA256') + if (client._lastChatSignature) signer.update(client._lastChatSignature) + signer.update(concat('UUID', client.uuid)) + + // Hash of chat body now opposed to signing plaintext. This lets server give us hashes for chat + // chain without needing to reveal message contents + if (message instanceof Buffer) { + signer.update(message) + } else { + const hash = crypto.createHash('sha256') + hash.update(concat('i64', salt, 'i64', timestamp / 1000n, 'pstring', message, 'i8', 70)) + if (preview) hash.update(preview) + for (const previousMessage of client._lastSeenMessages) { + hash.update(concat('i8', 70, 'UUID', previousMessage.sender)) + hash.update(previousMessage.signature) + } + // Feed hash back into signing payload + signer.update(hash.digest()) + } + + client._lastChatSignature = signer.sign(client.profileKeys.private) + } else { + // 1.19 + const signable = concat('i64', salt, 'UUID', client.uuid, 'i64', timestamp / 1000n, 'pstring', JSON.stringify({ text: message })) + client._lastChatSignature = crypto.sign('RSA-SHA256', signable, client.profileKeys.private) + } + + return client._lastChatSignature + } + + client.verifyMessage = (pubKey, packet) => { + if (mcData.supportFeature('chainedChatWithHashing')) { // 1.19.1+ + // Verification handled internally in 1.19.1+ as previous messages must be stored to verify future messages + throw new Error("Please listen to the 'playerChat' event instead to check message validity. client.verifyMessage is deprecated and only works on version 1.19.") + } + + if (pubKey instanceof Buffer) pubKey = crypto.createPublicKey({ key: pubKey, format: 'der', type: 'spki' }) + const signable = concat('i64', packet.salt, 'UUID', packet.senderUuid, 'i64', packet.timestamp / 1000n, 'pstring', packet.signedChatContent) + return crypto.verify('RSA-SHA256', signable, pubKey, packet.signature) + } + + // Report a chat message. + client.reportPlayer = (uuid, reason, reportedSignatures, comments) => { + const evidence = [] + + function addEvidence (entry) { + evidence.push({ + previousHeaderSignature: entry.previousSignature, + uuid: entry.senderUuid, + message: entry.message, + messageHash: entry.messageHash, + signature: entry.signature, + timestamp: entry.timestamp, + salt: entry.salt, + lastSeen: entry.lastSeen + }) + } + + for (let i = 0; i < client._lastChatHistory.capacity; i++) { + const entry = client._lastChatHistory[i] + for (const reportSig of reportedSignatures) { + if (reportSig.equals(entry.signature)) addEvidence(entry) + } + } + + return client.authflow.mca.reportPlayerChat({ + reason, + comments, + messages: evidence, + reportedPlayer: uuid, + createdTime: Date.now(), + clientVersion: client.version, + serverAddress: options.host + ':' + options.port, + realmInfo: undefined // { realmId, slotId } + }) + } +} + +class SignatureCache extends Array { + capacity = 128 + index = 0 + + push (e) { + if (!e) return + + this[this.index++] = e + this.index %= this.capacity + } +} + +class LastSeenMessagesWithInvalidation extends Array { + capacity = 20 + offset = 0 + pending = 0 + + push (e) { + if (!e) return false + + this[this.offset] = { pending: true, signature: e } + this.offset = (this.offset + 1) % this.capacity + this.pending++ + return true + } +} + +class LastSeenMessages extends Array { + capacity = 5 + pending = 0 + + push (e) { + if (!e || !e.signature || e.signature.length === 0) return false // We do not acknowledge unsigned messages + + // Insert a new entry at the top and shift everything to the right + let last = this[0] + this[0] = e + if (last && last.sender !== e.sender) { + for (let i = 1; i < this.capacity; i++) { + const current = this[i] + this[i] = last + last = current + // If we found an existing entry for the sender ID, we can stop shifting + if (!current || (current.sender === e.sender)) break + } + } + return true + } +} diff --git a/src/client/encrypt.js b/src/client/encrypt.js index d9ff607c8..63cc2bd96 100644 --- a/src/client/encrypt.js +++ b/src/client/encrypt.js @@ -3,6 +3,7 @@ const crypto = require('crypto') const debug = require('debug')('minecraft-protocol') const yggdrasil = require('yggdrasil') +const { concat } = require('../transforms/binaryStream') module.exports = function (client, options) { const yggdrasilServer = yggdrasil.server({ agent: options.agent, host: options.sessionServer || 'https://sessionserver.mojang.com' }) @@ -42,13 +43,33 @@ module.exports = function (client, options) { } function sendEncryptionKeyResponse () { + const mcData = require('minecraft-data')(client.version) + const pubKey = mcPubKeyToPem(packet.publicKey) const encryptedSharedSecretBuffer = crypto.publicEncrypt({ key: pubKey, padding: crypto.constants.RSA_PKCS1_PADDING }, sharedSecret) const encryptedVerifyTokenBuffer = crypto.publicEncrypt({ key: pubKey, padding: crypto.constants.RSA_PKCS1_PADDING }, packet.verifyToken) - client.write('encryption_begin', { - sharedSecret: encryptedSharedSecretBuffer, - verifyToken: encryptedVerifyTokenBuffer - }) + + if (mcData.supportFeature('signatureEncryption')) { + const salt = BigInt(Date.now()) + client.write('encryption_begin', { + sharedSecret: encryptedSharedSecretBuffer, + hasVerifyToken: client.profileKeys == null, + crypto: client.profileKeys + ? { + salt, + messageSignature: crypto.sign('sha256WithRSAEncryption', + concat('buffer', packet.verifyToken, 'i64', salt), client.profileKeys.private) + } + : { + verifyToken: encryptedVerifyTokenBuffer + } + }) + } else { + client.write('encryption_begin', { + sharedSecret: encryptedSharedSecretBuffer, + verifyToken: encryptedVerifyTokenBuffer + }) + } client.setEncryption(sharedSecret) } } @@ -58,7 +79,7 @@ module.exports = function (client, options) { function mcPubKeyToPem (mcPubKeyBuffer) { let pem = '-----BEGIN PUBLIC KEY-----\n' let base64PubKey = mcPubKeyBuffer.toString('base64') - const maxLineLength = 65 + const maxLineLength = 64 while (base64PubKey.length > 0) { pem += base64PubKey.substring(0, maxLineLength) + '\n' base64PubKey = base64PubKey.substring(maxLineLength) diff --git a/src/client/microsoftAuth.js b/src/client/microsoftAuth.js index 7f88cf119..8aea9890f 100644 --- a/src/client/microsoftAuth.js +++ b/src/client/microsoftAuth.js @@ -2,19 +2,24 @@ const path = require('path') const { Authflow: PrismarineAuth, Titles } = require('prismarine-auth') const minecraftFolderPath = require('minecraft-folder-path') const debug = require('debug')('minecraft-protocol') +const { RealmAPI } = require('prismarine-realms') -async function authenticate (client, options) { +function validateOptions (options) { if (!options.profilesFolder) { options.profilesFolder = path.join(minecraftFolderPath, 'nmp-cache') } - if (options.authTitle === undefined) { options.authTitle = Titles.MinecraftNintendoSwitch options.deviceType = 'Nintendo' + options.flow = 'live' } +} + +async function authenticate (client, options) { + validateOptions(options) - const Authflow = new PrismarineAuth(options.username, options.profilesFolder, options, options.onMsaCode) - const { token, entitlements, profile } = await Authflow.getMinecraftJavaToken({ fetchProfile: true }).catch(e => { + if (!client.authflow) client.authflow = new PrismarineAuth(options.username, options.profilesFolder, options, options.onMsaCode) + const { token, entitlements, profile, certificates } = await client.authflow.getMinecraftJavaToken({ fetchProfile: true, fetchCertificates: !options.disableChatSigning }).catch(e => { if (options.password) console.warn('Sign in failed, try removing the password field\n') if (e.toString().includes('Not Found')) console.warn(`Please verify that the account ${options.username} owns Minecraft\n`) throw e @@ -32,13 +37,47 @@ async function authenticate (client, options) { selectedProfile: profile, availableProfile: [profile] } + Object.assign(client, certificates) client.session = session client.username = profile.name + options.accessToken = token client.emit('session', session) options.connect(client) } +async function realmAuthenticate (client, options) { + validateOptions(options) + + client.authflow = new PrismarineAuth(options.username, options.profilesFolder, options, options.onMsaCode) + + const api = RealmAPI.from(client.authflow, 'java') + const realms = await api.getRealms() + + debug('realms', realms) + + if (!realms || !realms.length) throw Error('Couldn\'t find any Realms for the authenticated account') + + let realm + + if (options.realms.realmId) { + realm = realms.find(e => e.id === Number(options.realms.realmId)) + } else if (options.realms.pickRealm) { + if (typeof options.realms.pickRealm !== 'function') throw Error('realms.pickRealm must be a function') + realm = await options.realms.pickRealm(realms) + } + + 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.') + + const { host, port } = await realm.getAddress() + + debug('realms connection', { host, port }) + + options.host = host + options.port = port +} + module.exports = { - authenticate + authenticate, + realmAuthenticate } diff --git a/src/client/auth.js b/src/client/mojangAuth.js similarity index 98% rename from src/client/auth.js rename to src/client/mojangAuth.js index ce74c7278..ea1a6d844 100644 --- a/src/client/auth.js +++ b/src/client/mojangAuth.js @@ -82,7 +82,7 @@ module.exports = async function (client, options) { remoteId: oldProfileObj?.remoteId ?? '', username: options.username, localId: profile, - type: (options.auth?.toLowerCase() === 'microsoft' ? 'Xbox' : 'Mojang'), + type: (options.auth?.toLowerCase() === 'mojang' ? 'Mojang' : 'Xbox'), persistent: true } auths.accounts[profile] = newProfileObj diff --git a/src/client/play.js b/src/client/play.js index a5efb73cc..4dc1c3139 100644 --- a/src/client/play.js +++ b/src/client/play.js @@ -1,11 +1,92 @@ const states = require('../states') +const signedChatPlugin = require('./chat') +const uuid = require('uuid-1345') module.exports = function (client, options) { + client.serverFeatures = {} + client.on('server_data', (packet) => { + client.serverFeatures = { + chatPreview: packet.previewsChat, + enforcesSecureChat: packet.enforcesSecureChat // in LoginPacket v>=1.20.5 + } + }) + + client.once('login', (packet) => { + if (packet.enforcesSecureChat) client.serverFeatures.enforcesSecureChat = packet.enforcesSecureChat + const mcData = require('minecraft-data')(client.version) + if (mcData.supportFeature('useChatSessions') && client.profileKeys && client.cipher && client.session.selectedProfile.id === client.uuid.replace(/-/g, '')) { + client._session = { + index: 0, + uuid: uuid.v4fast() + } + + client.write('chat_session_update', { + sessionUUID: client._session.uuid, + expireTime: client.profileKeys ? BigInt(client.profileKeys.expiresOn.getTime()) : undefined, + publicKey: client.profileKeys ? client.profileKeys.public.export({ type: 'spki', format: 'der' }) : undefined, + signature: client.profileKeys ? client.profileKeys.signatureV2 : undefined + }) + } + }) + client.once('success', onLogin) function onLogin (packet) { - client.state = states.PLAY + const mcData = require('minecraft-data')(client.version) client.uuid = packet.uuid client.username = packet.username + + if (mcData.supportFeature('hasConfigurationState')) { + client.write('login_acknowledged', {}) + enterConfigState(onReady) + // Server can tell client to re-enter config state + client.on('start_configuration', () => enterConfigState()) + } else { + client.state = states.PLAY + onReady() + } + + function enterConfigState (finishCb) { + if (client.state === states.CONFIGURATION) return + // If we are returning to the configuration state from the play state, we ahve to acknowledge it. + if (client.state === states.PLAY) { + client.write('configuration_acknowledged', {}) + } + client.state = states.CONFIGURATION + client.once('select_known_packs', () => { + client.write('select_known_packs', { packs: [] }) + }) + // Server should send finish_configuration on its own right after sending the client a dimension codec + // for login (that has data about world height, world gen, etc) after getting a login success from client + client.once('finish_configuration', () => { + client.write('finish_configuration', {}) + client.state = states.PLAY + finishCb?.() + }) + } + + function onReady () { + if (mcData.supportFeature('signedChat')) { + if (options.disableChatSigning && client.serverFeatures.enforcesSecureChat) { + throw new Error('"disableChatSigning" was enabled in client options, but server is enforcing secure chat') + } + signedChatPlugin(client, options) + } else { + client.on('chat', (packet) => { + client.emit(packet.position === 0 ? 'playerChat' : 'systemChat', { + formattedMessage: packet.message, + sender: packet.sender, + positionId: packet.position, + verified: false + }) + }) + } + + function unsignedChat (message) { + client.write('chat', { message }) + } + client.chat = client._signedChat || unsignedChat + client.emit('playerJoin') + } } } diff --git a/src/client/pluginChannels.js b/src/client/pluginChannels.js index eb8614176..671eb452f 100644 --- a/src/client/pluginChannels.js +++ b/src/client/pluginChannels.js @@ -1,11 +1,13 @@ const ProtoDef = require('protodef').ProtoDef const minecraft = require('../datatypes/minecraft') const debug = require('debug')('minecraft-protocol') +const nbt = require('prismarine-nbt') module.exports = function (client, options) { const mcdata = require('minecraft-data')(options.version || require('../version').defaultVersion) const channels = [] - const proto = new ProtoDef() + const proto = new ProtoDef(options.validateChannelProtocol ?? true) + nbt.addTypesToInterpreter('big', proto) proto.addTypes(mcdata.protocol.types) proto.addTypes(minecraft) proto.addType('registerarr', [readDumbArr, writeDumbArr, sizeOfDumbArr]) @@ -14,16 +16,18 @@ module.exports = function (client, options) { client.unregisterChannel = unregisterChannel client.writeChannel = writeChannel - client.registerChannel('REGISTER', ['registerarr', []]) - client.registerChannel('UNREGISTER', ['registerarr', []]) - - if (options.protocolVersion >= 385) { // 1.13-pre3 (385) added Added Login Plugin Message (https://wiki.vg/Protocol_History#1.13-pre3) + const above385 = mcdata.version.version >= 385 + if (above385) { // 1.13-pre3 (385) added Added Login Plugin Message (https://wiki.vg/Protocol_History#1.13-pre3) client.on('login_plugin_request', onLoginPluginRequest) } + const channelNames = above385 ? ['minecraft:register', 'minecraft:unregister'] : ['REGISTER', 'UNREGISTER'] + + client.registerChannel(channelNames[0], ['registerarr', []]) + client.registerChannel(channelNames[1], ['registerarr', []]) function registerChannel (name, parser, custom) { if (custom) { - client.writeChannel('REGISTER', name) + client.writeChannel(channelNames[0], [name]) } if (parser) proto.addType(name, parser) channels.push(name) @@ -32,7 +36,7 @@ module.exports = function (client, options) { function unregisterChannel (channel, custom) { if (custom) { - client.writeChannel('UNREGISTER', channel) + client.writeChannel(channelNames[1], [channel]) } const index = channels.find(function (name) { return channel === name @@ -49,7 +53,14 @@ module.exports = function (client, options) { return channel === packet.channel }) if (channel) { - if (proto.types[channel]) { packet.data = proto.parsePacketBuffer(channel, packet.data).data } + if (proto.types[channel]) { + try { + packet.data = proto.parsePacketBuffer(channel, packet.data).data + } catch (error) { + client.emit('error', error) + return + } + } debug('read custom payload ' + channel + ' ' + packet.data) client.emit(channel, packet.data) } @@ -64,7 +75,7 @@ module.exports = function (client, options) { function writeChannel (channel, params) { debug('write custom payload ' + channel + ' ' + params) client.write('custom_payload', { - channel: channel, + channel, data: proto.createPacketBuffer(channel, params) }) } @@ -87,7 +98,7 @@ module.exports = function (client, options) { function writeDumbArr (value, buf, offset) { // TODO: Remove trailing \0 value.forEach(function (v) { - offset += this.write(v, buf, offset, 'cstring', {}) + offset += proto.write(v, buf, offset, 'cstring') }) return offset } diff --git a/src/client/setProtocol.js b/src/client/setProtocol.js index 5eea6527a..3842f45ae 100644 --- a/src/client/setProtocol.js +++ b/src/client/setProtocol.js @@ -13,9 +13,10 @@ module.exports = function (client, options) { } function next () { + const mcData = require('minecraft-data')(client.version) let taggedHost = options.host if (client.tagHost) taggedHost += client.tagHost - if (client.fakeHost) taggedHost = options.fakeHost + if (options.fakeHost) taggedHost = options.fakeHost client.write('set_protocol', { protocolVersion: options.protocolVersion, @@ -25,7 +26,18 @@ module.exports = function (client, options) { }) client.state = states.LOGIN client.write('login_start', { - username: client.username + username: client.username, + signature: (client.profileKeys && !mcData.supportFeature('useChatSessions')) + ? { + timestamp: BigInt(client.profileKeys.expiresOn.getTime()), // should probably be called "expireTime" + // Remove padding on the public key: not needed in vanilla server but matches how vanilla client looks + publicKey: client.profileKeys.public.export({ type: 'spki', format: 'der' }), + signature: mcData.supportFeature('profileKeySignatureV2') + ? client.profileKeys.signatureV2 + : client.profileKeys.signature + } + : null, + playerUUID: client.session?.selectedProfile?.id ?? client.uuid }) } } diff --git a/src/client/versionChecking.js b/src/client/versionChecking.js index 25ccebb5d..418544203 100644 --- a/src/client/versionChecking.js +++ b/src/client/versionChecking.js @@ -1,11 +1,23 @@ +const states = require('../states') + module.exports = function (client, options) { client.on('disconnect', message => { if (!message.reason) { return } - const parsed = JSON.parse(message.reason) + // Prevent the disconnect packet handler in the versionChecking code from triggering on PLAY or CONFIGURATION state disconnects + // Since version checking only happens during that HANDSHAKE / LOGIN state. + if (client.state === states.PLAY || client.state === states.CONFIGURATION) { return } + let parsed + try { + parsed = JSON.parse(message.reason) + } catch (error) { + return + } let text = parsed.text ? parsed.text : parsed let versionRequired - if (text.translate && text.translate.startsWith('multiplayer.disconnect.outdated_')) { versionRequired = text.with[0] } else { + if (text.translate && (text.translate.startsWith('multiplayer.disconnect.outdated_') || text.translate.startsWith('multiplayer.disconnect.incompatible'))) { + versionRequired = text.with[0] + } else { if (text.extra) text = text.extra[0].text versionRequired = /(?:Outdated client! Please use|Outdated server! I'm still on) (.+)/.exec(text) versionRequired = versionRequired ? versionRequired[1] : null diff --git a/src/createClient.js b/src/createClient.js index ffdc5edc9..912e331e6 100644 --- a/src/createClient.js +++ b/src/createClient.js @@ -1,12 +1,12 @@ 'use strict' -const Client = require('./client') +const DefaultClientImpl = require('./client') const assert = require('assert') const encrypt = require('./client/encrypt') const keepalive = require('./client/keepalive') const compress = require('./client/compress') -const auth = require('./client/auth') +const auth = require('./client/mojangAuth') const microsoftAuth = require('./client/microsoftAuth') const setProtocol = require('./client/setProtocol') const play = require('./client/play') @@ -14,13 +14,15 @@ const tcpDns = require('./client/tcp_dns') const autoVersion = require('./client/autoVersion') const pluginChannels = require('./client/pluginChannels') const versionChecking = require('./client/versionChecking') +const uuid = require('./datatypes/uuid') module.exports = createClient function createClient (options) { assert.ok(options, 'options is required') assert.ok(options.username, 'username is required') - if (!options.version) { options.version = false } + if (!options.version && !options.realms) { options.version = false } + if (options.realms && options.auth !== 'microsoft') throw new Error('Currently Realms can only be joined with auth: "microsoft"') // TODO: avoid setting default version if autoVersion is enabled const optVersion = options.version || require('./version').defaultVersion @@ -30,23 +32,50 @@ function createClient (options) { options.majorVersion = version.majorVersion options.protocolVersion = version.version const hideErrors = options.hideErrors || false + const Client = options.Client || DefaultClientImpl const client = new Client(false, version.minecraftVersion, options.customPackets, hideErrors) tcpDns(client, options) - if (options.auth === 'microsoft') { - microsoftAuth.authenticate(client, options) + if (options.auth instanceof Function) { + options.auth(client, options) + onReady() } else { - auth(client, options) + switch (options.auth) { + case 'mojang': + 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') + auth(client, options) + onReady() + break + case 'microsoft': + if (options.realms) { + microsoftAuth.realmAuthenticate(client, options).then(() => microsoftAuth.authenticate(client, options)).catch((err) => client.emit('error', err)).then(onReady) + } else { + microsoftAuth.authenticate(client, options).catch((err) => client.emit('error', err)) + onReady() + } + break + case 'offline': + default: + client.username = options.username + client.uuid = uuid.nameToMcOfflineUUID(client.username) + options.auth = 'offline' + options.connect(client) + onReady() + break + } + } + + function onReady () { + if (options.version === false) autoVersion(client, options) + setProtocol(client, options) + keepalive(client, options) + encrypt(client, options) + play(client, options) + compress(client, options) + pluginChannels(client, options) + versionChecking(client, options) } - if (options.version === false) autoVersion(client, options) - setProtocol(client, options) - keepalive(client, options) - encrypt(client, options) - play(client, options) - compress(client, options) - pluginChannels(client, options) - versionChecking(client, options) return client } diff --git a/src/createServer.js b/src/createServer.js index 77620ad64..4a56c0ad9 100644 --- a/src/createServer.js +++ b/src/createServer.js @@ -1,6 +1,6 @@ 'use strict' -const Server = require('./server') +const DefaultServerImpl = require('./server') const NodeRSA = require('node-rsa') const plugins = [ require('./server/handshake'), @@ -20,9 +20,12 @@ function createServer (options = {}) { motd = 'A Minecraft server', 'max-players': maxPlayersOld = 20, maxPlayers: maxPlayersNew = 20, + Server = DefaultServerImpl, version, favicon, - customPackets + customPackets, + motdMsg, // This is when you want to send formated motd's from ChatMessage instances + socketType = 'tcp' } = options const maxPlayers = options['max-players'] !== undefined ? maxPlayersOld : maxPlayersNew @@ -37,10 +40,13 @@ function createServer (options = {}) { const server = new Server(mcversion.minecraftVersion, customPackets, hideErrors) server.mcversion = mcversion server.motd = motd + server.motdMsg = motdMsg server.maxPlayers = maxPlayers server.playerCount = 0 server.onlineModeExceptions = Object.create(null) server.favicon = favicon + server.options = options + options.registryCodec = options.registryCodec || mcData.registryCodec || mcData.loginPacket?.dimensionCodec // The RSA keypair can take some time to generate // and is only needed for online-mode @@ -60,6 +66,10 @@ function createServer (options = {}) { server.on('connection', function (client) { plugins.forEach(plugin => plugin(client, server, options)) }) - server.listen(port, host) + if (socketType === 'ipc') { + server.listen(host) + } else { + server.listen(port, host) + } return server } diff --git a/src/datatypes/checksums.js b/src/datatypes/checksums.js new file mode 100644 index 000000000..1d516c70e --- /dev/null +++ b/src/datatypes/checksums.js @@ -0,0 +1,20 @@ +// Compute chat checksum using Java's Arrays.hashCode algorithm to match vanilla client +function computeChatChecksum (lastSeenMessages) { + if (!lastSeenMessages || lastSeenMessages.length === 0) return 1 + + let checksum = 1 + for (const message of lastSeenMessages) { + if (message.signature) { + let sigHash = 1 + for (let i = 0; i < message.signature.length; i++) { + sigHash = (31 * sigHash + message.signature[i]) & 0xffffffff + } + checksum = (31 * checksum + sigHash) & 0xffffffff + } + } + // Convert to byte + const result = checksum & 0xff + return result === 0 ? 1 : result +} + +module.exports = { computeChatChecksum } diff --git a/src/datatypes/compiler-minecraft.js b/src/datatypes/compiler-minecraft.js index 508b2e8df..9068641a2 100644 --- a/src/datatypes/compiler-minecraft.js +++ b/src/datatypes/compiler-minecraft.js @@ -1,8 +1,10 @@ +/* eslint-disable no-return-assign */ const UUID = require('uuid-1345') const minecraft = require('./minecraft') module.exports = { Read: { + varlong: ['native', minecraft.varlong[0]], UUID: ['native', (buffer, offset) => { return { value: UUID.stringify(buffer.slice(offset, 16 + offset)), @@ -15,8 +17,6 @@ module.exports = { size: buffer.length - offset } }], - nbt: ['native', minecraft.nbt[0]], - optionalNbt: ['native', minecraft.optionalNbt[0]], compressedNbt: ['native', minecraft.compressedNbt[0]], entityMetadataLoop: ['parametrizable', (compiler, { type, endVal }) => { let code = 'let cursor = offset\n' @@ -41,11 +41,41 @@ module.exports = { code += ' if ((item & 128) === 0) return { value: data, size: cursor - offset }\n' code += '}' return compiler.wrapCode(code) + }], + registryEntryHolder: ['parametrizable', (compiler, opts) => { + return compiler.wrapCode(` +const { value: n, size: nSize } = ${compiler.callType('varint')} +if (n !== 0) { + return { value: { ${opts.baseName}: n - 1 }, size: nSize } +} else { + const holder = ${compiler.callType(opts.otherwise.type, 'offset + nSize')} + return { value: { ${opts.otherwise.name}: holder.value }, size: nSize + holder.size } +} + `.trim()) + }], + registryEntryHolderSet: ['parametrizable', (compiler, opts) => { + return compiler.wrapCode(` + const { value: n, size: nSize } = ${compiler.callType('varint')} + if (n === 0) { + const base = ${compiler.callType(opts.base.type, 'offset + nSize')} + return { value: { ${opts.base.name}: base.value }, size: base.size + nSize } + } else { + const set = [] + let accSize = nSize + for (let i = 0; i < n - 1; i++) { + const entry = ${compiler.callType(opts.otherwise.type, 'offset + accSize')} + set.push(entry.value) + accSize += entry.size + } + return { value: { ${opts.otherwise.name}: set }, size: accSize } + } + `.trim()) }] }, Write: { + varlong: ['native', minecraft.varlong[1]], UUID: ['native', (value, buffer, offset) => { - const buf = UUID.parse(value) + const buf = value.length === 32 ? Buffer.from(value, 'hex') : UUID.parse(value) buf.copy(buffer, offset) return offset + 16 }], @@ -53,8 +83,6 @@ module.exports = { value.copy(buffer, offset) return offset + value.length }], - nbt: ['native', minecraft.nbt[1]], - optionalNbt: ['native', minecraft.optionalNbt[1]], compressedNbt: ['native', minecraft.compressedNbt[1]], entityMetadataLoop: ['parametrizable', (compiler, { type, endVal }) => { let code = 'for (const i in value) {\n' @@ -74,15 +102,47 @@ module.exports = { code += '}\n' code += 'return offset' return compiler.wrapCode(code) + }], + registryEntryHolder: ['parametrizable', (compiler, opts) => { + const baseName = `value.${opts.baseName}` + const otherwiseName = `value.${opts.otherwise.name}` + return compiler.wrapCode(` +if (${baseName} != null) { + offset = ${compiler.callType(`${baseName} + 1`, 'varint')} +} else if (${otherwiseName}) { + offset += 1 + offset = ${compiler.callType(`${otherwiseName}`, opts.otherwise.type)} +} else { + throw new Error('registryEntryHolder type requires "${baseName}" or "${otherwiseName}" fields to be set') +} +return offset + `.trim()) + }], + registryEntryHolderSet: ['parametrizable', (compiler, opts) => { + const baseName = `value.${opts.base.name}` + const otherwiseName = `value.${opts.otherwise.name}` + return compiler.wrapCode(` +if (${baseName} != null) { + offset = ${compiler.callType(0, 'varint')} + offset = ${compiler.callType(`${baseName}`, opts.base.type)} +} else if (${otherwiseName}) { + offset = ${compiler.callType(`${otherwiseName}.length + 1`, 'varint')} + for (let i = 0; i < ${otherwiseName}.length; i++) { + offset = ${compiler.callType(`${otherwiseName}[i]`, opts.otherwise.type)} + } +} else { + throw new Error('registryEntryHolder type requires "${opts.base.name}" or "${opts.otherwise.name}" fields to be set') +} +return offset + `.trim()) }] }, SizeOf: { + varlong: ['native', minecraft.varlong[2]], UUID: ['native', 16], restBuffer: ['native', (value) => { return value.length }], - nbt: ['native', minecraft.nbt[2]], - optionalNbt: ['native', minecraft.optionalNbt[2]], compressedNbt: ['native', minecraft.compressedNbt[2]], entityMetadataLoop: ['parametrizable', (compiler, { type }) => { let code = 'let size = 1\n' @@ -99,6 +159,41 @@ module.exports = { code += '}\n' code += 'return size' return compiler.wrapCode(code) + }], + registryEntryHolder: ['parametrizable', (compiler, opts) => { + const baseName = `value.${opts.baseName}` + const otherwiseName = `value.${opts.otherwise.name}` + return compiler.wrapCode(` +let size = 0 +if (${baseName} != null) { + size += ${compiler.callType(`${baseName} + 1`, 'varint')} +} else if (${otherwiseName}) { + size += 1 + size += ${compiler.callType(`${otherwiseName}`, opts.otherwise.type)} +} else { + throw new Error('registryEntryHolder type requires "${baseName}" or "${otherwiseName}" fields to be set') +} +return size + `.trim()) + }], + registryEntryHolderSet: ['parametrizable', (compiler, opts) => { + const baseName = `value.${opts.base.name}` + const otherwiseName = `value.${opts.otherwise.name}` + return compiler.wrapCode(` +let size = 0 +if (${baseName} != null) { + size += ${compiler.callType(0, 'varint')} + size += ${compiler.callType(`${baseName}`, opts.base.type)} +} else if (${otherwiseName}) { + size += ${compiler.callType(`${otherwiseName}.length + 1`, 'varint')} + for (let i = 0; i < ${otherwiseName}.length; i++) { + size += ${compiler.callType(`${otherwiseName}[i]`, opts.otherwise.type)} + } +} else { + throw new Error('registryEntryHolder type requires "${opts.base.name}" or "${opts.otherwise.name}" fields to be set') +} +return size + `.trim()) }] } } diff --git a/src/datatypes/minecraft.js b/src/datatypes/minecraft.js index b9b576145..09e90355a 100644 --- a/src/datatypes/minecraft.js +++ b/src/datatypes/minecraft.js @@ -3,11 +3,11 @@ const nbt = require('prismarine-nbt') const UUID = require('uuid-1345') const zlib = require('zlib') +const [readVarInt, writeVarInt, sizeOfVarInt] = require('protodef').types.varint module.exports = { + varlong: [readVarLong, writeVarLong, sizeOfVarLong], UUID: [readUUID, writeUUID, 16], - nbt: [readNbt, writeNbt, sizeOfNbt], - optionalNbt: [readOptionalNbt, writeOptionalNbt, sizeOfOptionalNbt], compressedNbt: [readCompressedNbt, writeCompressedNbt, sizeOfCompressedNbt], restBuffer: [readRestBuffer, writeRestBuffer, sizeOfRestBuffer], entityMetadataLoop: [readEntityMetadata, writeEntityMetadata, sizeOfEntityMetadata], @@ -15,6 +15,18 @@ module.exports = { } const PartialReadError = require('protodef').utils.PartialReadError +function readVarLong (buffer, offset) { + return readVarInt(buffer, offset) +} + +function writeVarLong (value, buffer, offset) { + return writeVarInt(value, buffer, offset) +} + +function sizeOfVarLong (value) { + return sizeOfVarInt(value) +} + function readUUID (buffer, offset) { if (offset + 16 > buffer.length) { throw new PartialReadError() } return { @@ -24,40 +36,13 @@ function readUUID (buffer, offset) { } function writeUUID (value, buffer, offset) { - const buf = UUID.parse(value) + const buf = value.length === 32 ? Buffer.from(value, 'hex') : UUID.parse(value) buf.copy(buffer, offset) return offset + 16 } -function readNbt (buffer, offset) { - return nbt.proto.read(buffer, offset, 'nbt') -} - -function writeNbt (value, buffer, offset) { - return nbt.proto.write(value, buffer, offset, 'nbt') -} - -function sizeOfNbt (value) { - return nbt.proto.sizeOf(value, 'nbt') -} - -function readOptionalNbt (buffer, offset) { - if (offset + 1 > buffer.length) { throw new PartialReadError() } - if (buffer.readInt8(offset) === 0) return { size: 1 } - return nbt.proto.read(buffer, offset, 'nbt') -} - -function writeOptionalNbt (value, buffer, offset) { - if (value === undefined) { - buffer.writeInt8(0, offset) - return offset + 1 - } - return nbt.proto.write(value, buffer, offset, 'nbt') -} - -function sizeOfOptionalNbt (value) { - if (value === undefined) { return 1 } - return nbt.proto.sizeOf(value, 'nbt') +function sizeOfNbt (value, { tagType } = { tagType: 'nbt' }) { + return nbt.proto.sizeOf(value, tagType) } // Length-prefixed compressed NBT, see differences: http://wiki.vg/index.php?title=Slot_Data&diff=6056&oldid=4753 @@ -97,7 +82,7 @@ function writeCompressedNbt (value, buffer, offset) { function sizeOfCompressedNbt (value) { if (value === undefined) { return 2 } - const nbtBuffer = Buffer.alloc(sizeOfNbt(value, 'nbt')) + const nbtBuffer = Buffer.alloc(sizeOfNbt(value, { tagType: 'nbt' })) nbt.proto.write(value, nbtBuffer, 0, 'nbt') const compressedNbt = zlib.gzipSync(nbtBuffer) // TODO: async diff --git a/src/datatypes/uuid.js b/src/datatypes/uuid.js new file mode 100644 index 000000000..23e9bbd8e --- /dev/null +++ b/src/datatypes/uuid.js @@ -0,0 +1,24 @@ +const crypto = require('crypto') +const UUID = require('uuid-1345') + +// https://github.com/openjdk-mirror/jdk7u-jdk/blob/f4d80957e89a19a29bb9f9807d2a28351ed7f7df/src/share/classes/java/util/UUID.java#L163 +function javaUUID (s) { + const hash = crypto.createHash('md5') + hash.update(s, 'utf8') + const buffer = hash.digest() + buffer[6] = (buffer[6] & 0x0f) | 0x30 + buffer[8] = (buffer[8] & 0x3f) | 0x80 + return buffer +} + +function nameToMcOfflineUUID (name) { + return (new UUID(javaUUID('OfflinePlayer:' + name))).toString() +} + +function fromIntArray (arr) { + const buf = Buffer.alloc(16) + arr.forEach((num, index) => { buf.writeInt32BE(num, index * 4) }) + return buf.toString('hex') +} + +module.exports = { nameToMcOfflineUUID, fromIntArray } diff --git a/src/index.d.ts b/src/index.d.ts index 37eff612f..bf52ca63d 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -4,6 +4,10 @@ import { EventEmitter } from 'events'; import { Socket } from 'net' import * as Stream from 'stream' import { Agent } from 'http' +import { Transform } from "readable-stream"; +import { BinaryLike, KeyObject } from 'crypto'; +import { Realm } from "prismarine-realms" +import NodeRSA from 'node-rsa'; type PromiseLike = Promise | void @@ -17,6 +21,8 @@ declare module 'minecraft-protocol' { username: string session?: SessionOption profile?: any + deserializer: FullPacketParser + serializer: Serializer latency: number customPackets: any protocolVersion: number @@ -31,6 +37,10 @@ declare module 'minecraft-protocol' { registerChannel(name: string, typeDefinition: any, custom?: boolean): void unregisterChannel(name: string): void writeChannel(channel: any, params: any): void + signMessage(message: string, timestamp: BigInt, salt?: number, preview?: string, acknowledgements?: Buffer[]): Buffer + verifyMessage(publicKey: Buffer | KeyObject, packet: object): boolean + 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 + chat(message: string, options?: { timestamp?: BigInt, salt?: BigInt, preview?: BinaryLike, didPreview?: boolean }): void on(event: 'error', listener: (error: Error) => PromiseLike): this on(event: 'packet', handler: (data: any, packetMeta: PacketMeta, buffer: Buffer, fullBuffer: Buffer) => PromiseLike): this on(event: 'raw', handler: (buffer: Buffer, packetMeta: PacketMeta) => PromiseLike): this @@ -40,17 +50,52 @@ declare module 'minecraft-protocol' { on(event: 'connect', handler: () => PromiseLike): this on(event: string, handler: (data: any, packetMeta: PacketMeta) => PromiseLike): this on(event: `raw.${string}`, handler: (buffer: Buffer, packetMeta: PacketMeta) => PromiseLike): this + on(event: 'playerChat', handler: (data: { + // (JSON string) The chat message preformatted, if done on server side + formattedMessage: string, + // (Plaintext) The chat message without formatting (for example no ` message` ; instead `message`), on version 1.19+ + plainMessage: string, + // (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 + unsignedContent?: string, + type: string, + sender: string, + senderName: string, + senderTeam: string, + targetName: string, + verified?: boolean + }) => PromiseLike): this + on(event: 'systemChat', handler: (data: { positionId: number, formattedMessage: string }) => PromiseLike): this + // Emitted after the player enters the PLAY state and can send and recieve game packets + on(event: 'playerJoin', handler: () => void): this once(event: 'error', listener: (error: Error) => PromiseLike): this once(event: 'packet', handler: (data: any, packetMeta: PacketMeta, buffer: Buffer, fullBuffer: Buffer) => PromiseLike): this once(event: 'raw', handler: (buffer: Buffer, packetMeta: PacketMeta) => PromiseLike): this once(event: 'sessionce', handler: (sessionce: any) => PromiseLike): this once(event: 'state', handler: (newState: States, oldState: States) => PromiseLike): this once(event: 'end', handler: (reasonce: string) => PromiseLike): this - once(event: 'concenect', handler: () => PromiseLike): this + once(event: 'connect', handler: () => PromiseLike): this once(event: string, handler: (data: any, packetMeta: PacketMeta) => PromiseLike): this once(event: `raw.${string}`, handler: (buffer: Buffer, packetMeta: PacketMeta) => PromiseLike): this } + class FullPacketParser extends Transform { + proto: any + mainType: any + noErrorLogging: boolean + constructor (proto: any, mainType: any, noErrorLogging?: boolean) + + parsePacketBuffer(buffer: Buffer): any + } + + class Serializer extends Transform { + proto: any + mainType: any + queue: Buffer + constructor(proto: any, mainType: any) + + createPacketBuffer(packet: any): any + } + export interface SessionOption { accessToken: string, /** My be needed for mojang auth. Is send by mojang on username + password auth */ @@ -80,7 +125,7 @@ declare module 'minecraft-protocol' { export interface ClientOptions { username: string port?: number - auth?: 'mojang' | 'microsoft' + auth?: 'mojang' | 'microsoft' | 'offline' | ((client: Client, options: ClientOptions) => void) password?: string host?: string clientToken?: string @@ -89,7 +134,7 @@ declare module 'minecraft-protocol' { authTitle?: string sessionServer?: string keepAlive?: boolean - closeTimeout?: number + closeTimeout?: number noPongTimeout?: number checkTimeoutInterval?: number version?: string @@ -100,26 +145,36 @@ declare module 'minecraft-protocol' { connect?: (client: Client) => void agent?: Agent fakeHost?: string - profilesFolder?: string + profilesFolder?: string | false onMsaCode?: (data: MicrosoftDeviceAuthorizationResponse) => void id?: number session?: SessionOption + validateChannelProtocol?: boolean, + realms?: RealmsOptions + // 1.19+ + disableChatSigning?: boolean + /** Pass custom client implementation if needed. */ + Client?: Client } export class Server extends EventEmitter { constructor(version: string, customPackets?: any) writeToClients(clients: Client[], name: string, params: any): void onlineModeExceptions: object - clients: ClientsMap + clients: { [key: number]: ServerClient } playerCount: number maxPlayers: number motd: string + motdMsg?: Object favicon: string + serverKey: NodeRSA close(): void on(event: 'connection', handler: (client: ServerClient) => PromiseLike): this on(event: 'error', listener: (error: Error) => PromiseLike): this on(event: 'login', handler: (client: ServerClient) => PromiseLike): this on(event: 'listening', listener: () => PromiseLike): this + // Emitted after the player enters the PLAY state and can send and recieve game packets + on(event: 'playerJoin', handler: (client: ServerClient) => void): this once(event: 'connection', handler: (client: ServerClient) => PromiseLike): this once(event: 'error', listener: (error: Error) => PromiseLike): this once(event: 'login', handler: (client: ServerClient) => PromiseLike): this @@ -128,6 +183,10 @@ declare module 'minecraft-protocol' { export interface ServerClient extends Client { id: number + /** You must call this function 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. */ + logSentMessageFromPeer(packet: object): boolean } export interface ServerOptions { @@ -136,17 +195,26 @@ declare module 'minecraft-protocol' { kickTimeout?: number checkTimeoutInterval?: number 'online-mode'?: boolean - beforePing?: (response: any, client: Client, callback?: (result: any) => any) => any + beforePing?: (response: any, client: Client, callback?: (error: any, result: any) => any) => any beforeLogin?: (client: Client) => void motd?: string + motdMsg?: Object maxPlayers?: number keepAlive?: boolean - version?: string + version?: string | false + fallbackVersion?: string favicon?: string customPackets?: any errorHandler?: (client: Client, error: Error) => void hideErrors?: boolean agent?: Agent + validateChannelProtocol?: boolean + /** (1.19+) Require connecting clients to have chat signing support enabled */ + enforceSecureProfile?: boolean + /** 1.19.1 & 1.19.2 only: If client should send previews of messages they are typing to the server */ + enableChatPreview?: boolean + socketType?: 'tcp' | 'ipc' + Server?: Server } export interface SerializerOptions { @@ -155,7 +223,7 @@ declare module 'minecraft-protocol' { state?: States version: string } - + export interface MicrosoftDeviceAuthorizationResponse { device_code: string user_code: string @@ -169,6 +237,7 @@ declare module 'minecraft-protocol' { HANDSHAKING = 'handshaking', LOGIN = 'login', PLAY = 'play', + CONFIGURATION = 'configuration', STATUS = 'status', } @@ -177,10 +246,6 @@ declare module 'minecraft-protocol' { state: States } - interface ClientsMap { - [key: number]: Client - } - export interface PingOptions { host?: string majorVersion?: string @@ -221,8 +286,14 @@ declare module 'minecraft-protocol' { latency: number } + export interface RealmsOptions { + realmId?: string + pickRealm?: (realms: Realm[]) => Realm + } + export const states: typeof States - export const supportedVersions: ['1.7', '1.8', '1.9', '1.10', '1.11.2', '1.12.2', '1.13.2', '1.14.4', '1.15.2', '1.16.5', '1.17.1'] + export const supportedVersions: string[] + export const defaultVersion: string export function createServer(options: ServerOptions): Server export function createClient(options: ClientOptions): Client diff --git a/src/index.js b/src/index.js index ab862afc1..2e861a550 100644 --- a/src/index.js +++ b/src/index.js @@ -7,13 +7,14 @@ const createClient = require('./createClient') const createServer = require('./createServer') module.exports = { - createClient: createClient, - createServer: createServer, - Client: Client, - Server: Server, + createClient, + createServer, + Client, + Server, states: require('./states'), createSerializer: serializer.createSerializer, createDeserializer: serializer.createDeserializer, ping: require('./ping'), - supportedVersions: require('./version').supportedVersions + supportedVersions: require('./version').supportedVersions, + defaultVersion: require('./version').defaultVersion } diff --git a/src/server.js b/src/server.js index 99cfcbac2..01b85a997 100644 --- a/src/server.js +++ b/src/server.js @@ -4,6 +4,7 @@ const net = require('net') const EventEmitter = require('events').EventEmitter const Client = require('./client') const states = require('./states') +const nbt = require('prismarine-nbt') const { createSerializer } = require('./transforms/serializer') class Server extends EventEmitter { @@ -26,10 +27,14 @@ class Server extends EventEmitter { self.socketServer.on('connection', socket => { const client = new Client(true, this.version, this.customPackets, this.hideErrors) client._end = client.end - client.end = function end (endReason, fullReason = JSON.stringify({ text: endReason })) { + client.end = function end (endReason, fullReason) { if (client.state === states.PLAY) { + fullReason ||= client._supportFeature('chatPacketsUseNbtComponents') + ? nbt.comp({ text: nbt.string(endReason) }) + : JSON.stringify({ text: endReason }) client.write('kick_disconnect', { reason: fullReason }) } else if (client.state === states.LOGIN) { + fullReason ||= JSON.stringify({ text: endReason }) client.write('disconnect', { reason: fullReason }) } client._end(endReason) diff --git a/src/server/chat.js b/src/server/chat.js new file mode 100644 index 000000000..2258fdc68 --- /dev/null +++ b/src/server/chat.js @@ -0,0 +1,273 @@ +const crypto = require('crypto') +const { computeChatChecksum } = require('../datatypes/checksums') +const concat = require('../transforms/binaryStream').concat +const debug = require('debug')('minecraft-protocol') +const messageExpireTime = 300000 // 5 min (ms) +const { mojangPublicKeyPem } = require('./constants') + +class VerificationError extends Error {} +function validateLastMessages (pending, lastSeen, lastRejected) { + if (lastRejected) { + const rejectedTime = pending.get(lastRejected.sender, lastRejected.signature) + if (rejectedTime) pending.acknowledge(lastRejected.sender, lastRejected.signature) + else throw new VerificationError(`Client rejected a message we never sent from '${lastRejected.sender}'`) + } + + let lastTimestamp + const seenSenders = new Set() + + for (const { messageSender, messageSignature } of lastSeen) { + if (pending.previouslyAcknowledged(messageSender, messageSignature)) continue + + const ts = pending.get(messageSender)(messageSignature) + if (!ts) { + throw new VerificationError(`Client saw a message that we never sent from '${messageSender}'`) + } else if (lastTimestamp && (ts < lastTimestamp)) { + throw new VerificationError(`Received messages out of order: Last acknowledged timestamp was at ${lastTimestamp}, now reading older message at ${ts}`) + } else if (seenSenders.has(messageSender)) { + // in the lastSeen array, last 5 messages from different players are stored, not just last 5 messages + throw new VerificationError(`Two last seen entries from same player not allowed: ${messageSender}`) + } else { + lastTimestamp = ts + seenSenders.add(messageSender) + pending.acknowledgePrior(messageSender, messageSignature) + } + } + + pending.setPreviouslyAcknowledged(lastSeen, lastRejected) +} + +module.exports = function (client, server, options) { + const mojangPubKey = crypto.createPublicKey(mojangPublicKeyPem) + const raise = (translatableError) => client.end(translatableError, JSON.stringify({ translate: translatableError })) + const pending = client.supportFeature('useChatSessions') ? new LastSeenMessages() : new Pending() + + if (!options.generatePreview) options.generatePreview = message => message + + function validateMessageChain (packet) { + try { + validateLastMessages(pending, packet.previousMessages, packet.lastRejectedMessage) + } catch (e) { + if (e instanceof VerificationError) { + raise('multiplayer.disconnect.chat_validation_failed') + if (!options.hideErrors) console.error(client.address, 'disconnected because', e) + } else { + client.emit('error', e) + } + } + } + + function validateSession (packet) { + try { + const unwrapped = pending.unwrap(packet.offset, packet.acknowledged) + + const length = Buffer.byteLength(packet.message, 'utf8') + const acknowledgements = unwrapped.length > 0 ? ['i32', unwrapped.length, 'buffer', Buffer.concat(...unwrapped)] : ['i32', 0] + + 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) + const valid = crypto.verify('RSA-SHA256', signable, client.profileKeys.public, packet.signature) + if (!valid) throw VerificationError('Invalid or missing message signature') + } catch (e) { + if (e instanceof VerificationError) { + raise('multiplayer.disconnect.chat_validation_failed') + if (!options.hideErrors) console.error(client.address, 'disconnected because', e) + } else { + client.emit('error', e) + } + } + } + + client.on('chat_session_update', (packet) => { + client._session = { + index: 0, + uuid: packet.sessionUuid + } + + const publicKey = crypto.createPublicKey({ key: packet.publicKey, format: 'der', type: 'spki' }) + const signable = concat('UUID', client.uuid, 'i64', packet.expireTime, 'buffer', publicKey.export({ type: 'spki', format: 'der' })) + + // This makes sure 'signable' when signed with the mojang private key equals signature in this packet + if (!crypto.verify('RSA-SHA1', signable, mojangPubKey, packet.signature)) { + debug('Signature mismatch') + raise('multiplayer.disconnect.invalid_public_key_signature') + return + } + client.profileKeys = { public: publicKey } + }) + + // Listen to chat messages and verify the `lastSeen` and `lastRejected` messages chain + let lastTimestamp + client.on('chat_message', (packet) => { + if (!options.enforceSecureProfile) return // nothing signable + + if ((lastTimestamp && packet.timestamp < lastTimestamp) || (packet.timestamp > Date.now())) { + return raise('multiplayer.disconnect.out_of_order_chat') + } + lastTimestamp = packet.timestamp + + // Validate checksum for 1.21.5+ + if (client.supportFeature('chatGlobalIndexAndChecksum') && options.enforceChatChecksum && packet.checksum !== undefined) { + const expectedChecksum = computeChatChecksum(client._lastSeenMessages || []) + if (packet.checksum !== 0 && packet.checksum !== expectedChecksum) { + return raise('multiplayer.disconnect.chat_validation_failed') + } + } + + // Checks here: 1) make sure client can chat, 2) chain/session is OK, 3) signature is OK, 4) log if expired + if (client.settings.disabledChat) return raise('chat.disabled.options') + if (client.supportFeature('chainedChatWithHashing')) validateMessageChain(packet) // 1.19.1 + if (client.supportFeature('useChatSessions')) validateSession(packet) // 1.19.3 + else if (!client.verifyMessage(packet)) raise('multiplayer.disconnect.unsigned_chat') + if ((BigInt(Date.now()) - packet.timestamp) > messageExpireTime) debug(client.socket.address(), 'sent expired message TS', packet.timestamp) + }) + + // Client will occasionally send a list of seen messages to the server, here we listen & check chain validity + client.on('message_acknowledgement', (packet) => { + if (client.supportFeature('useChatSessions')) { + const valid = client._lastSeenMessages.applyOffset(packet.count) + if (!valid) { + raise('multiplayer.disconnect.chat_validation_failed') + if (!options.hideErrors) console.error(client.address, 'disconnected because', VerificationError('Failed to validate message acknowledgements')) + } + } else validateMessageChain(packet) + }) + + client.verifyMessage = (packet) => { + if (!client.profileKeys) return null + if (client.supportFeature('useChatSessions')) throw Error('client.verifyMessage is deprecated. Does not work for 1.19.3 and above') + + if (client.supportFeature('chainedChatWithHashing')) { // 1.19.1 + if (client._lastChatSignature === packet.signature) return true // Called twice + const verifier = crypto.createVerify('RSA-SHA256') + if (client._lastChatSignature) verifier.update(client._lastChatSignature) + verifier.update(concat('UUID', client.uuid)) + + // Hash of chat body now opposed to signing plaintext. This lets server give us hashes for chat + // chain without needing to reveal message contents + if (packet.bodyDigest) { + // Header + verifier.update(packet.bodyDigest) + } else { + // Player Chat + const hash = crypto.createHash('sha256') + hash.update(concat('i64', packet.salt, 'i64', packet.timestamp / 1000n, 'pstring', packet.message, 'i8', 70)) + if (packet.signedPreview) hash.update(options.generatePreview(packet.message)) + for (const { messageSender, messageSignature } of packet.previousMessages) { + hash.update(concat('i8', 70, 'UUID', messageSender)) + hash.update(messageSignature) + } + // Feed hash back into signing payload + verifier.update(hash.digest()) + } + client._lastChatSignature = packet.signature + return verifier.verify(client.profileKeys.public, packet.signature) + } else { // 1.19 + const signable = concat('i64', packet.salt, 'UUID', client.uuid, 'i64', packet.timestamp, 'pstring', packet.message) + return crypto.verify('sha256WithRSAEncryption', signable, client.profileKeys.public, packet.signature) + } + } + + // On 1.19.1+, outbound messages from server (client->SERVER->players) are logged so we can verify + // the last seen message field in inbound chat packets + client.logSentMessageFromPeer = (chatPacket) => { + if (!options.enforceSecureProfile || !server.features.signedChat) return // nothing signable + + pending.add(chatPacket.senderUuid, chatPacket.signature, chatPacket.timestamp) + if (pending.length > 4096) { + raise('multiplayer.disconnect.too_many_pending_chats') + return false + } + return true + } +} + +class LastSeenMessages extends Array { + tracking = 20 + + constructor () { + super() + for (let i = 0; i < this.tracking; i++) this.push(null) + } + + add (sender, signature) { + this.push({ signature, pending: true }) + } + + applyOffset (offset) { + const diff = this.length - this.tracking + if (offset >= 0 && offset <= diff) { + this.splice(0, offset) + return true + } + + return false + } + + unwrap (offset, acknowledged) { + if (!this.applyOffset(offset)) throw VerificationError('Failed to validate message acknowledgements') + + const n = (acknowledged[2] << 16) | (acknowledged[1] << 8) | acknowledged[0] + + const unwrapped = [] + for (let i = 0; i < this.tracking; i++) { + const ack = n & (1 << i) + const tracked = this[i] + if (ack) { + if (tracked === null) throw VerificationError('Failed to validate message acknowledgements') + + tracked.pending = false + unwrapped.push(tracked.signature) + } else { + if (tracked !== null && !tracked.pending) throw VerificationError('Failed to validate message acknowledgements') + + this[i] = null + } + } + + return unwrapped + } +} + +class Pending extends Array { + m = {} + lastSeen = [] + + get (sender, signature) { + return this.m[sender]?.[signature] + } + + add (sender, signature, ts) { + this.m[sender] = this.m[sender] || {} + this.m[sender][signature] = ts + this.push([sender, signature]) + } + + acknowledge (sender, username) { + delete this.m[sender][username] + this.splice(this.findIndex(([a, b]) => a === sender && b === username), 1) + } + + acknowledgePrior (sender, signature) { + for (let i = 0; i < this.length; i++) { + const [a, b] = this[i] + delete this.m[a] + if (a === sender && b === signature) { + this.splice(0, i) + break + } + } + } + + // Once we've acknowledged that the client has saw the messages we sent, + // we delete it from our map & pending list. However, the client may keep it in + // their 5-length lastSeen list anyway. Once we verify/ack the client's lastSeen array, + // we need to store it in memory to allow those entries to be approved again without + // erroring about a message we never sent in the next serverbound message packet we get. + setPreviouslyAcknowledged (lastSeen, lastRejected = {}) { + this.lastSeen = lastSeen.map(e => Object.values(e)).push(Object.values(lastRejected)) + } + + previouslyAcknowledged (sender, signature) { + return this.lastSeen.some(([a, b]) => a === sender && b === signature) + } +} diff --git a/src/server/constants.js b/src/server/constants.js new file mode 100644 index 000000000..be719cad3 --- /dev/null +++ b/src/server/constants.js @@ -0,0 +1,3 @@ +module.exports = { + 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-----' +} diff --git a/src/server/handshake.js b/src/server/handshake.js index 12d7fa3be..1e51b00c8 100644 --- a/src/server/handshake.js +++ b/src/server/handshake.js @@ -1,6 +1,6 @@ const states = require('../states') -module.exports = function (client, server, { version }) { +module.exports = function (client, server, { version, fallbackVersion }) { client.once('set_protocol', onHandshake) function onHandshake (packet) { @@ -8,11 +8,24 @@ module.exports = function (client, server, { version }) { client.serverPort = packet.serverPort client.protocolVersion = packet.protocolVersion - if (version === false || version === undefined) { - if (require('minecraft-data')(client.protocolVersion)) { + if (version === false) { + const mcData = require('minecraft-data')(client.protocolVersion) + if (mcData) { client.version = client.protocolVersion + client._supportFeature = mcData.supportFeature + client._hasBundlePacket = mcData.supportFeature('hasBundlePacket') } else { - client.end('Protocol version ' + client.protocolVersion + ' is not supported') + let fallback + if (fallbackVersion !== undefined) { + fallback = require('minecraft-data')(fallbackVersion) + } + if (fallback) { + client.version = fallback.version.version + client._supportFeature = fallback.supportFeature + client._hasBundlePacket = fallback.supportFeature('hasBundlePacket') + } else { + client.end('Protocol version ' + client.protocolVersion + ' is not supported') + } } } else if (client.protocolVersion !== server.mcversion.version && packet.nextState !== 1) { client.end('Wrong protocol version, expected: ' + server.mcversion.version + ' and you are using: ' + client.protocolVersion) diff --git a/src/server/login.js b/src/server/login.js index e9f0b58a8..ec40ed913 100644 --- a/src/server/login.js +++ b/src/server/login.js @@ -1,16 +1,31 @@ -const UUID = require('uuid-1345') -const bufferEqual = require('buffer-equal') +const uuid = require('../datatypes/uuid') const crypto = require('crypto') const pluginChannels = require('../client/pluginChannels') const states = require('../states') const yggdrasil = require('yggdrasil') +const chatPlugin = require('./chat') +const { concat } = require('../transforms/binaryStream') +const { mojangPublicKeyPem } = require('./constants') +const debug = require('debug')('minecraft-protocol') +const NodeRSA = require('node-rsa') +const nbt = require('prismarine-nbt') +/** + * @param {import('../index').Client} client + * @param {import('../index').Server} server + * @param {Object} options + */ module.exports = function (client, server, options) { + const mojangPubKey = crypto.createPublicKey(mojangPublicKeyPem) + const raise = (translatableError) => client.end(translatableError, JSON.stringify({ translate: translatableError })) const yggdrasilServer = yggdrasil.server({ agent: options.agent }) const { 'online-mode': onlineMode = true, kickTimeout = 30 * 1000, - errorHandler: clientErrorHandler = (client, err) => client.end(err) + errorHandler: clientErrorHandler = function (client, err) { + if (!options.hideErrors) console.debug('Disconnecting client because error', err) + client.end(err) + } } = options let serverId @@ -21,15 +36,55 @@ module.exports = function (client, server, options) { client.on('end', () => { clearTimeout(loginKickTimer) }) - client.once('login_start', onLogin) + function kickForNotLoggingIn () { + client.end('LoginTimeout') + } let loginKickTimer = setTimeout(kickForNotLoggingIn, kickTimeout) function onLogin (packet) { + const mcData = require('minecraft-data')(client.version) + client.supportFeature = mcData.supportFeature + client.username = packet.username const isException = !!server.onlineModeExceptions[client.username.toLowerCase()] const needToVerify = (onlineMode && !isException) || (!onlineMode && isException) + + if (mcData.supportFeature('signatureEncryption')) { + if (options.enforceSecureProfile && !packet.signature) { + raise('multiplayer.disconnect.missing_public_key') + return + } + } + + if (packet.signature) { + if (packet.signature.timestamp < BigInt(Date.now())) { + debug('Client sent expired tokens') + raise('multiplayer.disconnect.invalid_public_key_signature') + return // expired tokens, client needs to restart game + } + + try { + const publicKey = crypto.createPublicKey({ key: packet.signature.publicKey, format: 'der', type: 'spki' }) + const signable = mcData.supportFeature('profileKeySignatureV2') + ? concat('UUID', packet.playerUUID, 'i64', packet.signature.timestamp, 'buffer', publicKey.export({ type: 'spki', format: 'der' })) + : Buffer.from(packet.signature.timestamp + mcPubKeyToPem(packet.signature.publicKey), 'utf8') // (expires at + publicKey) + + // This makes sure 'signable' when signed with the mojang private key equals signature in this packet + if (!crypto.verify('RSA-SHA1', signable, mojangPubKey, packet.signature.signature)) { + debug('Signature mismatch') + raise('multiplayer.disconnect.invalid_public_key_signature') + return + } + client.profileKeys = { public: publicKey } + } catch (err) { + debug(err) + raise('multiplayer.disconnect.invalid_public_key') + return + } + } + if (needToVerify) { serverId = crypto.randomBytes(4).toString('hex') client.verifyToken = crypto.randomBytes(4) @@ -41,32 +96,57 @@ module.exports = function (client, server, options) { client.publicKey = Buffer.from(publicKeyStr, 'base64') client.once('encryption_begin', onEncryptionKeyResponse) client.write('encryption_begin', { - serverId: serverId, + serverId, publicKey: client.publicKey, - verifyToken: client.verifyToken + verifyToken: client.verifyToken, + shouldAuthenticate: true }) } else { loginClient() } } - function kickForNotLoggingIn () { - client.end('LoginTimeout') - } - function onEncryptionKeyResponse (packet) { - let sharedSecret - try { - const verifyToken = crypto.privateDecrypt({ key: server.serverKey.exportKey(), padding: crypto.constants.RSA_PKCS1_PADDING }, packet.verifyToken) - if (!bufferEqual(client.verifyToken, verifyToken)) { + if (client.profileKeys) { + if (options.enforceSecureProfile && packet.hasVerifyToken) { + raise('multiplayer.disconnect.missing_public_key') + return // Unexpected - client has profile keys, and we expect secure profile + } + } + + const keyRsa = new NodeRSA(server.serverKey.exportKey('pkcs1'), 'private', { encryptionScheme: 'pkcs1' }) + keyRsa.setOptions({ environment: 'browser' }) + + if (packet.hasVerifyToken === false) { + // 1.19, hasVerifyToken is set and equal to false IF chat signing is enabled + // This is the default action starting in 1.19.1. + const signable = concat('buffer', client.verifyToken, 'i64', packet.crypto.salt) + if (!crypto.verify('sha256WithRSAEncryption', signable, client.profileKeys.public, packet.crypto.messageSignature)) { + raise('multiplayer.disconnect.invalid_public_key_signature') + return + } + } else { + const encryptedToken = packet.hasVerifyToken ? packet.crypto.verifyToken : packet.verifyToken + try { + const decryptedToken = keyRsa.decrypt(encryptedToken) + + if (!client.verifyToken.equals(decryptedToken)) { + client.end('DidNotEncryptVerifyTokenProperly') + return + } + } catch { client.end('DidNotEncryptVerifyTokenProperly') return } - sharedSecret = crypto.privateDecrypt({ key: server.serverKey.exportKey(), padding: crypto.constants.RSA_PKCS1_PADDING }, packet.sharedSecret) + } + let sharedSecret + try { + sharedSecret = keyRsa.decrypt(packet.sharedSecret) } catch (e) { client.end('DidNotEncryptVerifyTokenProperly') return } + client.setEncryption(sharedSecret) const isException = !!server.onlineModeExceptions[client.username.toLowerCase()] @@ -90,32 +170,41 @@ module.exports = function (client, server, options) { } } - // https://github.com/openjdk-mirror/jdk7u-jdk/blob/f4d80957e89a19a29bb9f9807d2a28351ed7f7df/src/share/classes/java/util/UUID.java#L163 - function javaUUID (s) { - const hash = crypto.createHash('md5') - hash.update(s, 'utf8') - const buffer = hash.digest() - buffer[6] = (buffer[6] & 0x0f) | 0x30 - buffer[8] = (buffer[8] & 0x3f) | 0x80 - return buffer - } - - function nameToMcOfflineUUID (name) { - return (new UUID(javaUUID('OfflinePlayer:' + name))).toString() - } - function loginClient () { const isException = !!server.onlineModeExceptions[client.username.toLowerCase()] if (onlineMode === false || isException) { - client.uuid = nameToMcOfflineUUID(client.username) + client.uuid = uuid.nameToMcOfflineUUID(client.username) } options.beforeLogin?.(client) 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 client.write('compress', { threshold: 256 }) // Default threshold is 256 client.compressionThreshold = 256 } - client.write('success', { uuid: client.uuid, username: client.username }) - client.state = states.PLAY + // TODO: find out what properties are on 'success' packet + client.write('success', { + uuid: client.uuid, + username: client.username, + properties: [] + }) + if (client.supportFeature('hasConfigurationState')) { + client.once('login_acknowledged', onClientLoginAck) + } else { + client.state = states.PLAY + } + client.settings = {} + + if (client.supportFeature('chainedChatWithHashing')) { // 1.19.1+ + const jsonMotd = JSON.stringify(server.motdMsg ?? { text: server.motd }) + const nbtMotd = nbt.comp({ text: nbt.string(server.motd) }) + client.write('server_data', { + motd: client.supportFeature('chatPacketsUseNbtComponents') ? nbtMotd : jsonMotd, + icon: server.favicon, // b64 + iconBytes: server.favicon ? Buffer.from(server.favicon, 'base64') : undefined, + previewsChat: options.enableChatPreview, + // Note: in 1.20.5+ user must send this with `login` + enforcesSecureChat: options.enforceSecureProfile + }) + } clearTimeout(loginKickTimer) loginKickTimer = null @@ -125,6 +214,39 @@ module.exports = function (client, server, options) { server.playerCount -= 1 }) pluginChannels(client, options) + if (client.supportFeature('signedChat')) chatPlugin(client, server, options) server.emit('login', client) + if (!client.supportFeature('hasConfigurationState')) { + server.emit('playerJoin', client) + } + } + + function onClientLoginAck () { + client.state = states.CONFIGURATION + if (client.supportFeature('segmentedRegistryCodecData')) { + for (const key in options.registryCodec) { + const entry = options.registryCodec[key] + client.write('registry_data', entry) + } + } else { + client.write('registry_data', { codec: options.registryCodec || {} }) + } + client.once('finish_configuration', () => { + client.state = states.PLAY + server.emit('playerJoin', client) + }) + client.write('finish_configuration', {}) + } +} + +function mcPubKeyToPem (mcPubKeyBuffer) { + let pem = '-----BEGIN RSA PUBLIC KEY-----\n' + let base64PubKey = mcPubKeyBuffer.toString('base64') + const maxLineLength = 76 + while (base64PubKey.length > 0) { + pem += base64PubKey.substring(0, maxLineLength) + '\n' + base64PubKey = base64PubKey.substring(maxLineLength) } + pem += '-----END RSA PUBLIC KEY-----\n' + return pem } diff --git a/src/server/ping.js b/src/server/ping.js index f72fca5f5..07a73478b 100644 --- a/src/server/ping.js +++ b/src/server/ping.js @@ -1,27 +1,51 @@ const endianToggle = require('endian-toggle') -module.exports = function (client, server, { beforePing = null }) { +module.exports = function (client, server, { beforePing = null, version, fallbackVersion }) { client.once('ping_start', onPing) client.once('legacy_server_list_ping', onLegacyPing) function onPing () { + let responseVersion = { + name: server.mcversion.minecraftVersion, + protocol: server.mcversion.version + } + + if (version === false) { + let minecraftData = require('minecraft-data')(client.protocolVersion) + if (!minecraftData && fallbackVersion !== undefined) { + minecraftData = require('minecraft-data')(fallbackVersion) + } + if (minecraftData) { + responseVersion = { + name: minecraftData.version.minecraftVersion, + protocol: minecraftData.version.version + } + } else { + responseVersion = { + name: client.version, + protocol: client.protocolVersion + } + } + } + const response = { - version: { - name: server.mcversion.minecraftVersion, - protocol: server.mcversion.version - }, + version: responseVersion, players: { max: server.maxPlayers, online: server.playerCount, sample: [] }, - description: { text: server.motd }, + description: server.motdMsg ?? { text: server.motd }, favicon: server.favicon } function answerToPing (err, response) { if (err) return - client.write('server_info', { response: JSON.stringify(response) }) + if (response === false) { + client.socket.destroy() + } else { + client.write('server_info', { response: JSON.stringify(response) }) + } } if (beforePing) { diff --git a/src/states.js b/src/states.js index ba4792fda..34bf360d0 100644 --- a/src/states.js +++ b/src/states.js @@ -4,6 +4,7 @@ const states = { HANDSHAKING: 'handshaking', STATUS: 'status', LOGIN: 'login', + CONFIGURATION: 'configuration', PLAY: 'play' } diff --git a/src/transforms/binaryStream.js b/src/transforms/binaryStream.js new file mode 100644 index 000000000..6f0f53ed5 --- /dev/null +++ b/src/transforms/binaryStream.js @@ -0,0 +1,24 @@ +const types = {} +Object.assign(types, require('protodef').types) +Object.assign(types, require('../datatypes/minecraft')) + +function concat (...args) { + let allocLen = 0 + for (let i = 0; i < args.length; i += 2) { + const type = args[i] + const value = args[i + 1] + const [,, s] = types[type] + allocLen += typeof s === 'number' ? s : s(value, {}) + } + const buffer = Buffer.alloc(allocLen) + let offset = 0 + for (let i = 0; i < args.length; i += 2) { + const type = args[i] + const value = args[i + 1] + offset = types[type][1](value, buffer, offset, {}) + } + return buffer +} + +// concat('i32', 22, 'i64', 2n) => +module.exports = { concat } diff --git a/src/transforms/encryption.js b/src/transforms/encryption.js index 00a82013d..75383df80 100644 --- a/src/transforms/encryption.js +++ b/src/transforms/encryption.js @@ -49,6 +49,6 @@ class Decipher extends Transform { } module.exports = { - createCipher: createCipher, - createDecipher: createDecipher + createCipher, + createDecipher } diff --git a/src/transforms/serializer.js b/src/transforms/serializer.js index fb154baea..76f7ef271 100644 --- a/src/transforms/serializer.js +++ b/src/transforms/serializer.js @@ -1,30 +1,36 @@ 'use strict' -const ProtoDef = require('protodef').ProtoDef -const Serializer = require('protodef').Serializer -const Parser = require('protodef').FullPacketParser +const { ProtoDef, Serializer, FullPacketParser } = require('protodef') const { ProtoDefCompiler } = require('protodef').Compiler +const nbt = require('prismarine-nbt') const minecraft = require('../datatypes/minecraft') const states = require('../states') const merge = require('lodash.merge') -const get = require('lodash.get') +const minecraftData = require('minecraft-data') const protocols = {} function createProtocol (state, direction, version, customPackets, compiled = true) { - const key = state + ';' + direction + ';' + version + (compiled ? ';c' : '') + const key = `${state};${direction};${version}${compiled ? ';c' : ''}` if (protocols[key]) { return protocols[key] } - const mcData = require('minecraft-data')(version) + const mcData = minecraftData(version) + const versionInfo = minecraftData.versionsByMinecraftVersion.pc[version] if (mcData === null) { throw new Error(`No data available for version ${version}`) + } else if (versionInfo && versionInfo.version !== mcData.version.version) { + // The protocol version returned by node-minecraft-data constructor does not match the data in minecraft-data's protocolVersions.json + throw new Error(`Unsupported protocol version '${versionInfo.version}' (attempted to use '${mcData.version.version}' data); try updating your packages with 'npm update'`) } + const mergedProtocol = merge(mcData.protocol, customPackets?.[mcData.version.majorVersion] ?? {}) + if (compiled) { const compiler = new ProtoDefCompiler() compiler.addTypes(require('../datatypes/compiler-minecraft')) - compiler.addProtocol(merge(mcData.protocol, get(customPackets, [mcData.version.majorVersion])), [state, direction]) + compiler.addProtocol(mergedProtocol, [state, direction]) + nbt.addTypesToCompiler('big', compiler) const proto = compiler.compileProtoDefSync() protocols[key] = proto return proto @@ -32,7 +38,8 @@ function createProtocol (state, direction, version, customPackets, compiled = tr const proto = new ProtoDef(false) proto.addTypes(minecraft) - proto.addProtocol(merge(mcData.protocol, get(customPackets, [mcData.version.majorVersion])), [state, direction]) + proto.addProtocol(mergedProtocol, [state, direction]) + nbt.addTypesToInterperter('big', proto) protocols[key] = proto return proto } @@ -42,10 +49,10 @@ function createSerializer ({ state = states.HANDSHAKING, isServer = false, versi } function createDeserializer ({ state = states.HANDSHAKING, isServer = false, version, customPackets, compiled = true, noErrorLogging = false } = {}) { - return new Parser(createProtocol(state, isServer ? 'toServer' : 'toClient', version, customPackets, compiled), 'packet', noErrorLogging) + return new FullPacketParser(createProtocol(state, isServer ? 'toServer' : 'toClient', version, customPackets, compiled), 'packet', noErrorLogging) } module.exports = { - createSerializer: createSerializer, - createDeserializer: createDeserializer + createSerializer, + createDeserializer } diff --git a/src/version.js b/src/version.js index 0afd42e12..7525bd703 100644 --- a/src/version.js +++ b/src/version.js @@ -1,6 +1,6 @@ 'use strict' module.exports = { - defaultVersion: '1.18', - supportedVersions: ['1.7', '1.8', '1.9', '1.10', '1.11.2', '1.12.2', '1.13.2', '1.14.4', '1.15.2', '1.16.5', '1.17.1', '1.18'] + defaultVersion: '1.21.8', + 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'] } diff --git a/test/benchmark.js b/test/benchmark.js index e31c901f0..1d24cd754 100644 --- a/test/benchmark.js +++ b/test/benchmark.js @@ -5,17 +5,19 @@ const ITERATIONS = 10000 const mc = require('../') const states = mc.states -const testDataWrite = [ - { name: 'keep_alive', params: { keepAliveId: 957759560 } }, - { name: 'chat', params: { message: ' Hello World!' } }, - { name: 'position_look', params: { x: 6.5, y: 65.62, stance: 67.24, z: 7.5, yaw: 0, pitch: 0, onGround: true } } - // TODO: add more packets for better quality data -] - for (const supportedVersion of mc.supportedVersions) { const mcData = require('minecraft-data')(supportedVersion) const version = mcData.version - describe('benchmark ' + version.minecraftVersion, function () { + const positionFlags = mcData.isNewerOrEqualTo('1.21.3') ? { flags: { onGround: true, hasHorizontalCollision: false } } : { onGround: true } + const testDataWrite = [ + { name: 'keep_alive', params: { keepAliveId: 957759560 } }, + // TODO: 1.19+ `chat` -> `player_chat` feature toggle + // { name: 'chat', params: { message: ' Hello World!' } }, + { name: 'position_look', params: { x: 6.5, y: 65.62, stance: 67.24, z: 7.5, yaw: 0, pitch: 0, ...positionFlags } } + // TODO: add more packets for better quality data + ] + + describe('benchmark ' + supportedVersion + 'v', function () { this.timeout(60 * 1000) const inputData = [] it('bench serializing', function (done) { diff --git a/test/clientTest.js b/test/clientTest.js index 079b6e6fa..63c2be290 100644 --- a/test/clientTest.js +++ b/test/clientTest.js @@ -2,25 +2,27 @@ const mc = require('../') const os = require('os') +const fs = require('fs') const path = require('path') const assert = require('power-assert') -const SURVIVE_TIME = 10000 const util = require('util') +const applyClientHelpers = require('./common/clientHelpers') +const download = util.promisify(require('minecraft-wrap').download) +const { getPort } = require('./common/util') + +const SURVIVE_TIME = 10000 const MC_SERVER_PATH = path.join(__dirname, 'server') const Wrap = require('minecraft-wrap').Wrap -const download = util.promisify(require('minecraft-wrap').download) - -const { getPort } = require('./common/util') - for (const supportedVersion of mc.supportedVersions) { let PORT = null const mcData = require('minecraft-data')(supportedVersion) const version = mcData.version const MC_SERVER_JAR_DIR = process.env.MC_SERVER_JAR_DIR || os.tmpdir() const MC_SERVER_JAR = MC_SERVER_JAR_DIR + '/minecraft_server.' + version.minecraftVersion + '.jar' - const wrap = new Wrap(MC_SERVER_JAR, MC_SERVER_PATH + '_' + supportedVersion, { + const MC_SERVER_DIR = MC_SERVER_PATH + '_' + supportedVersion + const wrap = new Wrap(MC_SERVER_JAR, MC_SERVER_DIR, { minMem: 1024, maxMem: 1024 }) @@ -28,7 +30,7 @@ for (const supportedVersion of mc.supportedVersions) { console.log(line) }) - describe('client ' + version.minecraftVersion, function () { + describe('client ' + supportedVersion + 'v', function () { this.timeout(10 * 60 * 1000) before(async function () { @@ -57,6 +59,8 @@ for (const supportedVersion of mc.supportedVersions) { 'server-port': PORT, motd: 'test1234', 'max-players': 120, + 'level-type': 'flat', + 'generate-structures': 'false', // 12m 'use-native-transport': 'false' // java 16 throws errors without this, https://www.spigotmc.org/threads/unable-to-access-address-of-buffer.311602 }, (err) => { if (err) reject(err) @@ -96,35 +100,130 @@ for (const supportedVersion of mc.supportedVersions) { }) }) + // chat/Style.java + const CLICK_EVENT = mcData.version['>=']('1.21.5') ? 'click_event' : 'clickEvent' it('connects successfully - offline mode', function (done) { - const client = mc.createClient({ + const client = applyClientHelpers(mc.createClient({ username: 'Player', version: version.minecraftVersion, - port: PORT - }) + port: PORT, + auth: 'offline' + })) client.on('error', err => done(err)) + + client.on('state', (state) => { + console.log('Client now in state', state) + }) + + // ** Dump some server data ** + fs.rmSync(MC_SERVER_DIR + '_registry_data.json', { force: true }) + client.on('raw.registry_data', (buffer) => { + fs.writeFileSync(MC_SERVER_DIR + '_registry_data.bin', buffer) + }) + client.on('registry_data', (json) => { + if (json.codec) { // Pre 1.20.5, codec is 1 json + fs.writeFileSync(MC_SERVER_DIR + '_registry_data.json', JSON.stringify(json)) + } else { // 1.20.5+, codec is many nbt's each with their own ids, merge them + let currentData = {} + if (fs.existsSync(MC_SERVER_DIR + '_registry_data.json')) { + currentData = JSON.parse(fs.readFileSync(MC_SERVER_DIR + '_registry_data.json', 'utf8')) + } + currentData[json.id] = json + fs.writeFileSync(MC_SERVER_DIR + '_registry_data.json', JSON.stringify(currentData)) + } + console.log('Wrote registry data') + }) + client.on('login', (packet) => { + fs.writeFileSync(MC_SERVER_DIR + '_login.json', JSON.stringify(packet)) + if (fs.existsSync(MC_SERVER_DIR + '_registry_data.json')) { + // generate a loginPacket.json for minecraft-data + const codec = JSON.parse(fs.readFileSync(MC_SERVER_DIR + '_registry_data.json')) + fs.writeFileSync(MC_SERVER_DIR + '_loginPacket.json', JSON.stringify({ + ...packet, + dimensionCodec: codec.codec || codec + }, null, 2)) + console.log('Wrote loginPacket.json') + } + }) + // ** End dumping code ** + const lineListener = function (line) { - const match = line.match(/\[Server thread\/INFO\]: <(.+?)> (.+)/) + const match = line.match(/\[Server thread\/INFO\]: (?:\[Not Secure\] )?<(.+?)> (.+)/) if (!match) return assert.strictEqual(match[1], 'Player') assert.strictEqual(match[2], 'hello everyone; I have logged in.') wrap.writeServer('say hello\n') + wrap.off('line', lineListener) } wrap.on('line', lineListener) let chatCount = 0 + client.on('login', function (packet) { - assert.strictEqual(packet.gameMode, 0) - client.write('chat', { - message: 'hello everyone; I have logged in.' - }) + if (packet.worldState) { // 1.20.5+ + assert.strictEqual(packet.worldState.gamemode, 'survival') + } else { + assert.strictEqual(packet.gameMode, 0) + } + client.chat('hello everyone; I have logged in.') }) - client.on('chat', function (packet) { + client.on('playerChat', function (data) { + chatCount += 1 + assert.ok(chatCount <= 2) + + if (!mcData.supportFeature('clientsideChatFormatting')) { + const message = JSON.parse(data.formattedMessage) + if (chatCount === 1) { + assert.strictEqual(message.translate, 'chat.type.text') + assert.deepEqual(message.with[0].clickEvent, { + action: 'suggest_command', + value: mcData.version.version > 340 ? '/tell Player ' : '/msg Player ' + }) + assert.deepEqual(message.with[0].text, 'Player') + assert.strictEqual(message.with[1], 'hello everyone; I have logged in.') + } else if (chatCount === 2) { + assert.strictEqual(message.translate, 'chat.type.announcement') + assert.strictEqual(message.with[0].text ? message.with[0].text : message.with[0], 'Server') + assert.deepEqual(message.with[1].extra + ? (message.with[1].extra[0].text + ? message.with[1].extra[0].text + : message.with[1].extra[0]) + : message.with[1].text, 'hello') + wrap.removeListener('line', lineListener) + client.end() + done() + } + } else { + // 1.19+ + const sender = JSON.parse(data.senderName) + const msgPayload = data.formattedMessage ? JSON.parse(data.formattedMessage) : data.plainMessage + const plainMessage = client.parseMessage(msgPayload).toString() + + if (chatCount === 1) { + assert.strictEqual(plainMessage, 'hello everyone; I have logged in.') + const clickEvent = sender[CLICK_EVENT] + assert.strictEqual(clickEvent.action, 'suggest_command') + assert.ok(['/tell Player ', '/msg Player '].includes(clickEvent.value || clickEvent.command)) + assert.strictEqual(sender.text, 'Player') + } else if (chatCount === 2) { + const plainSender = client.parseMessage(sender).toString() + assert.strictEqual(plainMessage, 'hello') + assert.strictEqual(plainSender, 'Server') + wrap.removeListener('line', lineListener) + client.end() + done() + } + } + }) + + client.on('systemChat', function (data) { + // For 1.7.10 chatCount += 1 assert.ok(chatCount <= 2) - const message = JSON.parse(packet.message) + + const message = JSON.parse(data.formattedMessage) if (chatCount === 1) { assert.strictEqual(message.translate, 'chat.type.text') - assert.deepEqual(message.with[0].clickEvent, { + assert.deepEqual(message.with[0][CLICK_EVENT], { action: 'suggest_command', value: mcData.version.version > 340 ? '/tell Player ' : '/msg Player ' }) @@ -146,16 +245,15 @@ for (const supportedVersion of mc.supportedVersions) { }) it('does not crash for ' + SURVIVE_TIME + 'ms', function (done) { - const client = mc.createClient({ + const client = applyClientHelpers(mc.createClient({ username: 'Player', version: version.minecraftVersion, - port: PORT - }) + port: PORT, + auth: 'offline' + })) client.on('error', err => done(err)) client.on('login', function () { - client.write('chat', { - message: 'hello everyone; I have logged in.' - }) + client.chat('hello everyone; I have logged in.') setTimeout(function () { client.end() done() @@ -164,10 +262,12 @@ for (const supportedVersion of mc.supportedVersions) { }) it('produce a decent error when connecting with the wrong version', function (done) { + if (process.platform === 'win32') return done() const client = mc.createClient({ username: 'Player', version: version.minecraftVersion === '1.8.8' ? '1.11.2' : '1.8.8', - port: PORT + port: PORT, + auth: 'offline' }) client.once('error', function (err) { if (err.message.startsWith('This server is version')) { @@ -211,12 +311,12 @@ for (const supportedVersion of mc.supportedVersions) { }) it('connects successfully - online mode', function (done) { - const client = mc.createClient({ + const client = applyClientHelpers(mc.createClient({ username: process.env.MC_USERNAME, password: process.env.MC_PASSWORD, version: version.minecraftVersion, port: PORT - }) + })) client.on('error', err => done(err)) const lineListener = function (line) { const match = line.match(/\[Server thread\/INFO\]: <(.+?)> (.+)/) @@ -231,9 +331,7 @@ for (const supportedVersion of mc.supportedVersions) { assert.strictEqual(packet.difficulty, 1) assert.strictEqual(packet.dimension, 0) assert.strictEqual(packet.gameMode, 0) - client.write('chat', { - message: 'hello everyone; I have logged in.' - }) + client.chat('hello everyone; I have logged in.') }) let chatCount = 0 client.on('chat', function (packet) { @@ -252,7 +350,8 @@ for (const supportedVersion of mc.supportedVersions) { const client = mc.createClient({ username: 'Player', version: version.minecraftVersion, - port: PORT + port: PORT, + auth: 'offline' }) client.on('error', err => done(err)) let gotKicked = false diff --git a/test/common/clientHelpers.js b/test/common/clientHelpers.js new file mode 100644 index 000000000..797b2325b --- /dev/null +++ b/test/common/clientHelpers.js @@ -0,0 +1,43 @@ +const Registry = require('prismarine-registry') +module.exports = client => { + client.nextMessage = (containing) => { + return new Promise((resolve) => { + function onChat (packet) { + const m = packet.formattedMessage || packet.unsignedChatContent || JSON.stringify({ text: packet.plainMessage }) + if (containing) { + if (m.includes(containing)) return finish(m) + else return + } + return finish(m) + } + client.on('playerChat', onChat) + client.on('systemChat', onChat) // For 1.7.10 + + function finish (m) { + client.off('playerChat', onChat) + client.off('systemChat', onChat) + resolve(m) + } + }) + } + + client.on('login', (packet) => { + client.registry ??= Registry(client.version) + if (packet.dimensionCodec) { + client.registry.loadDimensionCodec(packet.dimensionCodec) + } + }) + client.on('registry_data', (data) => { + client.registry ??= Registry(client.version) + client.registry.loadDimensionCodec(data.codec || data) + }) + + client.on('playerJoin', () => { + const ChatMessage = require('prismarine-chat')(client.registry || client.version) + client.parseMessage = (comp) => { + return new ChatMessage(comp) + } + }) + + return client +} diff --git a/test/cyclePacketTest.js b/test/cyclePacketTest.js new file mode 100644 index 000000000..1a224d8da --- /dev/null +++ b/test/cyclePacketTest.js @@ -0,0 +1,60 @@ +/* eslint-env mocha */ +// Tests packet serialization/deserialization from with raw binary from minecraft-packets +const { createSerializer, createDeserializer, states, supportedVersions } = require('minecraft-protocol') +const mcPackets = require('minecraft-packets') +const assert = require('assert') + +const makeClientSerializer = version => createSerializer({ state: states.PLAY, version, isServer: true }) +const makeClientDeserializer = version => createDeserializer({ state: states.PLAY, version }) + +for (const supportedVersion of supportedVersions) { + let serializer, deserializer, data + const mcData = require('minecraft-data')(supportedVersion) + const version = mcData.version + + function convertBufferToObject (buffer) { + return deserializer.parsePacketBuffer(buffer) + } + + function convertObjectToBuffer (object) { + return serializer.createPacketBuffer(object) + } + + function testBuffer (buffer, [packetName, packetIx]) { + const parsed = convertBufferToObject(buffer).data + const parsedBuffer = convertObjectToBuffer(parsed) + const areEq = buffer.equals(parsedBuffer) + if (!areEq) { + console.log(`Error when testing ${+packetIx + 1} ${packetName} packet`) + console.direct(parsed, { depth: null }) + console.log('original buffer', buffer.toString('hex')) + console.log('cycled buffer', parsedBuffer.toString('hex')) + } + assert.strictEqual(areEq, true, `Error when testing ${+packetIx + 1} ${packetName} packet`) + } + describe(`Test cycle packet for version ${supportedVersion}v`, () => { + before(() => { + serializer = makeClientSerializer(version.minecraftVersion) + deserializer = makeClientDeserializer(version.minecraftVersion) + }) + data = mcPackets.pc[version.minecraftVersion] + it('Has packet data', () => { + if (data === undefined) { + // many version do not have data, so print a log for now + // assert when most versions have packet data + console.log(`Version ${version.minecraftVersion} has no packet dump.`) + } + }) + // server -> client + if (data !== undefined) { + Object.entries(data['from-server']).forEach(([packetName, packetData]) => { + it(`${packetName} packet`, function () { + if (packetName === 'sound_effect') return this.skip() // sound_effect structure is out of date in minecraft-packets + for (const i in packetData) { + testBuffer(packetData[i].raw, [packetName, i]) + } + }) + }) + } + }) +} diff --git a/test/docTest.js b/test/docTest.js new file mode 100644 index 000000000..1b3d80fea --- /dev/null +++ b/test/docTest.js @@ -0,0 +1,16 @@ +/* eslint-env mocha */ + +const mc = require('../') +const fs = require('fs') +const assert = require('assert') +const path = require('path') + +const readmeContent = fs.readFileSync(path.join(__dirname, '/../docs/README.md'), { encoding: 'utf8', flag: 'r' }) + +for (const supportedVersion of mc.supportedVersions) { + describe('doc ' + supportedVersion + 'v', function () { + it('mentions the supported version in the readme', () => { + assert.ok(readmeContent.includes(supportedVersion), `${supportedVersion} should be mentionned in the README.md but it is not`) + }) + }) +} diff --git a/test/non-par-test.js b/test/non-par-test.js deleted file mode 100644 index 81c420cb0..000000000 --- a/test/non-par-test.js +++ /dev/null @@ -1,39 +0,0 @@ -/* eslint-env mocha */ -// Tests packet serialization/deserialization from with raw binary from minecraft-packets -const { createSerializer, createDeserializer, states } = require('minecraft-protocol') -const mcPackets = require('minecraft-packets') -const assert = require('assert') - -const makeClientSerializer = version => createSerializer({ state: states.PLAY, version, isServer: true }) -const makeClientDeserializer = version => createDeserializer({ state: states.PLAY, version }) - -Object.entries(mcPackets.pc).forEach(([ver, data]) => { - let serializer, deserializer - - function convertBufferToObject (buffer) { - return deserializer.parsePacketBuffer(buffer) - } - - function convertObjectToBuffer (object) { - return serializer.createPacketBuffer(object) - } - - function testBuffer (buffer, [packetName, packetIx]) { - const parsed = convertBufferToObject(buffer).data - const parsedBuffer = convertObjectToBuffer(parsed) - const areEq = buffer.equals(parsedBuffer) - assert.strictEqual(areEq, true, `Error when testing ${+packetIx + 1} ${packetName} packet`) - } - describe(`Test version ${ver}`, () => { - serializer = makeClientSerializer(ver) - deserializer = makeClientDeserializer(ver) - // server -> client - Object.entries(data['from-server']).forEach(([packetName, packetData]) => { - it(`${packetName} packet`, () => { - for (const i in packetData) { - testBuffer(packetData[i].raw, [packetName, i]) - } - }) - }) - }) -}) diff --git a/test/packetTest.js b/test/packetTest.js index e70004f9f..81ee16958 100644 --- a/test/packetTest.js +++ b/test/packetTest.js @@ -18,7 +18,6 @@ function evalCount (count, fields) { const slotValue = { present: true, blockId: 5, - itemCount: 56, itemDamage: 2, nbtData: { type: 'compound', @@ -32,6 +31,127 @@ const slotValue = { test6: { type: 'compound', value: { test: { type: 'int', value: 4 } } }, test7: { type: 'intArray', value: [12, 42] } } + }, + // 1.20.5 + itemCount: 1, + itemId: 1111, + addedComponentCount: 0, + removedComponentCount: 0, + components: [], + removeComponents: [] +} + +const nbtValue = { + type: 'compound', + name: 'test', + value: { + test1: { type: 'int', value: 4 }, + test2: { type: 'long', value: [12, 42] }, + test3: { type: 'byteArray', value: [32] }, + test4: { type: 'string', value: 'ohi' }, + test5: { type: 'list', value: { type: 'int', value: [4] } }, + test6: { type: 'compound', value: { test: { type: 'int', value: 4 } } }, + test7: { type: 'intArray', value: [12, 42] } + } +} + +function getFixedPacketPayload (version, packetName) { + if (packetName === 'teams') { + if (version['>=']('1.21.6')) { + return { + team: 'test_team', + mode: 'add', + name: nbtValue, + flags: 'always', + nameTagVisibility: 'always', + collisionRule: 'always', + formatting: 0, // no formatting + prefix: nbtValue, + suffix: nbtValue, + players: ['player1', 'player2'] + } + } + } + if (packetName === 'declare_recipes') { + if (version['>=']('1.21.3')) { + return { + recipes: [ + { + name: 'minecraft:campfire_input', + items: [ + 903, + 976 + ] + } + ], + stoneCutterRecipes: [ + { + input: { + ids: [ + 6 + ] + }, + slotDisplay: { + type: 'item_stack', + data: slotValue + } + } + ] + } + } else if (version['>=']('1.20.5')) { + return { + recipes: [ + { + name: 'minecraft:crafting_decorated_pot', + type: 'minecraft:crafting_decorated_pot', + data: { + category: 0 + } + } + ] + } + } + } + if (packetName === 'player_info') { + if (version.majorVersion === '1.7') return { playerName: 'test', online: true, ping: 1 } + if (version['>=']('1.19.3')) { + return { + action: { + _value: 63, + add_player: true, + initialize_chat: true, + update_game_mode: true, + update_listed: true, + update_latency: true, + update_display_name: true + }, + data: [ + { + uuid: 'a01e3843-e521-3998-958a-f459800e4d11', + player: { name: 'Player', properties: [] }, + chatSession: undefined, + gamemode: 0, + listed: 1, + latency: 0, + displayName: undefined + } + ] + } + } else { + return { + action: 'add_player', + data: [ + { + uuid: 'a01e3843-e521-3998-958a-f459800e4d11', + name: 'Player', + properties: [], + gamemode: 0, + ping: 0, + displayName: undefined + } + ] + } + } } } @@ -40,10 +160,25 @@ const values = { i16: -123, u16: 123, varint: 1, + varlong: -20, i8: -10, u8: 8, + ByteArray: [], string: 'hi hi this is my client string', - buffer: Buffer.alloc(8), + buffer: function (typeArgs, context) { + let count + if (typeof typeArgs.count === 'number') { + count = typeArgs.count + } else if (typeof typeArgs.count === 'object') { + count = evalCount(typeArgs.count, context) + } else if (typeArgs.count !== undefined) { + count = getField(typeArgs.count, context) + } else if (typeArgs.countType !== undefined) { + count = 8 + } + + return Buffer.alloc(count) + }, array: function (typeArgs, context) { let count if (typeof typeArgs.count === 'number') { @@ -68,7 +203,7 @@ const values = { } Object.keys(typeArgs).forEach(function (index) { const v = typeArgs[index].name === 'type' && typeArgs[index].type === 'string' && typeArgs[2] !== undefined && - typeArgs[2].type !== undefined + typeArgs[2].type !== undefined ? (typeArgs[2].type[1].fields['minecraft:crafting_shapeless'] === undefined ? 'crafting_shapeless' : 'minecraft:crafting_shapeless') : getValue(typeArgs[index].type, results) if (typeArgs[index].anon) { @@ -82,50 +217,42 @@ const values = { delete results['..'] return results }, + vec2f: { + x: 0, y: 0 + }, + vec3f: { + x: 0, y: 0, z: 0 + }, + vec3f64: { + x: 0, y: 0, z: 0 + }, + vec4f: { + x: 0, y: 0, z: 0, w: 0 + }, + vec3i: { + x: 0, y: 0, z: 0 + }, count: 1, // TODO : might want to set this to a correct value bool: true, f64: 99999.2222, f32: -333.444, slot: slotValue, - nbt: { - type: 'compound', - name: 'test', - value: { - test1: { type: 'int', value: 4 }, - test2: { type: 'long', value: [12, 42] }, - test3: { type: 'byteArray', value: [32] }, - test4: { type: 'string', value: 'ohi' }, - test5: { type: 'list', value: { type: 'int', value: [4] } }, - test6: { type: 'compound', value: { test: { type: 'int', value: 4 } } }, - test7: { type: 'intArray', value: [12, 42] } - } - }, - optionalNbt: { - type: 'compound', - name: 'test', - value: { - test1: { type: 'int', value: 4 }, - test2: { type: 'long', value: [12, 42] }, - test3: { type: 'byteArray', value: [32] }, - test4: { type: 'string', value: 'ohi' }, - test5: { type: 'list', value: { type: 'int', value: [4] } }, - test6: { type: 'compound', value: { test: { type: 'int', value: 4 } } }, - test7: { type: 'intArray', value: [12, 42] } - } + Slot: slotValue, + UntrustedSlot: slotValue, + HashedSlot: slotValue, + SlotComponent: { + type: 'hide_tooltip' }, - compressedNbt: { - type: 'compound', - name: 'test', - value: { - test1: { type: 'int', value: 4 }, - test2: { type: 'long', value: [12, 42] }, - test3: { type: 'byteArray', value: [32] }, - test4: { type: 'string', value: 'ohi' }, - test5: { type: 'list', value: { type: 'int', value: [4] } }, - test6: { type: 'compound', value: { test: { type: 'int', value: 4 } } }, - test7: { type: 'intArray', value: [12, 42] } - } + ChatTypes: { + registryIndex: 1 }, + SlotComponentType: 0, + nbt: nbtValue, + optionalNbt: nbtValue, + compressedNbt: nbtValue, + anonymousNbt: nbtValue, + anonOptionalNbt: nbtValue, + previousMessages: [], i64: [0, 1], u64: [0, 1], entityMetadata: [ @@ -157,8 +284,8 @@ const values = { const i = typeArgs.fields[getField(typeArgs.compareTo, context)] if (i === undefined) { if (typeArgs.default === undefined) { - throw new Error("couldn't find the field " + typeArgs.compareTo + - ' of the compareTo and the default is not defined') + typeArgs.default = 'void' + // throw new Error("couldn't find the field " + typeArgs.compareTo + ' of the compareTo and the default is not defined') } return getValue(typeArgs.default, context) } else { return getValue(i, context) } @@ -173,10 +300,122 @@ const values = { }) return results }, + mapper: '', tags: [{ tagName: 'hi', entries: [1, 2, 3, 4, 5] }], ingredient: [slotValue], particleData: null, - chunkBlockEntity: { x: 10, y: 11, z: 12, type: 25 } + chunkBlockEntity: { x: 10, y: 11, z: 12, type: 25 }, + command_node: { + flags: { + has_custom_suggestions: 1, + has_redirect_node: 1, + has_command: 1, + command_node_type: 2 + }, + children: [23, 29], + redirectNode: 83, + extraNodeData: { + name: 'command_node name', + parser: 'brigadier:double', + properties: { + flags: { + max_present: 1, + min_present: 1 + }, + min: -5.0, + max: 256.0 + }, + suggestionType: 'minecraft:summonable_entities' + } + }, + bitflags: function (typeArgs, context) { + const results = {} + Object.keys(typeArgs.flags).forEach(function (index) { + results[typeArgs.flags[index]] = true + }) + return results + }, + registryEntryHolder (typeArgs, context) { + return { [typeArgs.baseName]: 1 } + }, + soundSource: 'master', + packedChunkPos: { + x: 10, + z: 12 + }, + particle: { + particleId: 0, + data: null + }, + Particle: {}, + SpawnInfo: { + dimension: 0, + name: 'minecraft:overworld', + hashedSeed: [ + 572061085, + 1191958278 + ], + gamemode: 'survival', + previousGamemode: 255, + isDebug: false, + isFlat: false, + portalCooldown: 0 + }, + MovementFlags: { + onGround: true, + hasHorizontalCollision: false + }, + ContainerID: 0, + PositionUpdateRelatives: { + x: true, + y: true, + z: true, + yaw: true, + pitch: true, + dx: true, + dy: true, + dz: true, + yawDelta: true + }, + RecipeDisplay: { + type: 'stonecutter', + data: { + ingredient: { type: 'empty' }, + result: { type: 'empty' }, + craftingStation: { type: 'empty' } + } + }, + SlotDisplay: { type: 'empty' }, + game_profile: { + name: 'test', + properties: [{ + key: 'foo', + value: 'bar' + }] + }, + optvarint: 1, + chat_session: { + uuid: '00112233-4455-6677-8899-aabbccddeeff', + publicKey: { + expireTime: 30, + keyBytes: [], + keySignature: [] + } + }, + IDSet: { ids: [2, 5] }, + ItemSoundHolder: { soundId: 1 }, + ChatTypesHolder: { chatType: 1 }, + ExactComponentMatcher: [], + HashedStack: { + itemId: 1, + count: 1, + addedComponents: [], + removedComponents: [] + }, + RecipeBookSetting: { + open: false, + filtering: false + } } function getValue (_type, packet) { @@ -197,11 +436,16 @@ for (const supportedVersion of mc.supportedVersions) { const version = mcData.version const packets = mcData.protocol - describe('packets ' + version.minecraftVersion, function () { + describe('packets ' + supportedVersion + 'v', function () { let client, server, serverClient before(async function () { PORT = await getPort() server = new Server(version.minecraftVersion) + if (mcData.supportFeature('mcDataHasEntityMetadata')) { + values.entityMetadata[0].type = 'byte' + } else { + values.entityMetadata[0].type = 0 + } return new Promise((resolve) => { console.log(`Using port for tests: ${PORT}`) server.once('listening', function () { @@ -228,30 +472,33 @@ for (const supportedVersion of mc.supportedVersions) { Object.keys(packets[state]).forEach(function (direction) { Object.keys(packets[state][direction].types) .filter(function (packetName) { - return packetName !== 'packet' && - packetName.startsWith('packet_') + return packetName !== 'packet' && packetName.startsWith('packet_') }) .forEach(function (packetName) { packetInfo = packets[state][direction].types[packetName] packetInfo = packetInfo || null + if (packetName.includes('bundle_delimiter')) return // not a real packet + if (['packet_set_projectile_power', 'packet_debug_sample_subscription'].includes(packetName)) return it(state + ',' + (direction === 'toServer' ? 'Server' : 'Client') + 'Bound,' + packetName, - callTestPacket(packetName.substr(7), packetInfo, state, direction === 'toServer')) + callTestPacket(mcData, packetName.substr(7), packetInfo, state, direction === 'toServer')) }) }) }) - function callTestPacket (packetName, packetInfo, state, toServer) { + function callTestPacket (mcData, packetName, packetInfo, state, toServer) { return function (done) { client.state = state serverClient.state = state - testPacket(packetName, packetInfo, state, toServer, done) + testPacket(mcData, packetName, packetInfo, state, toServer, done) } } - function testPacket (packetName, packetInfo, state, toServer, done) { + function testPacket (mcData, packetName, packetInfo, state, toServer, done) { // empty object uses default values - const packet = getValue(packetInfo, {}) + const packet = getFixedPacketPayload(mcData.version, packetName) || getValue(packetInfo, {}) if (toServer) { + console.log('Writing to server', packetName, JSON.stringify(packet)) serverClient.once(packetName, function (receivedPacket) { + console.log('Recv', packetName) try { assertPacketsMatch(packet, receivedPacket) } catch (e) { @@ -262,7 +509,9 @@ for (const supportedVersion of mc.supportedVersions) { }) client.write(packetName, packet) } else { + console.log('Writing to client', packetName, JSON.stringify(packet)) client.once(packetName, function (receivedPacket) { + console.log('Recv', packetName) assertPacketsMatch(packet, receivedPacket) done() }) diff --git a/test/serverTest.js b/test/serverTest.js index 73a3a0c06..b2e515408 100644 --- a/test/serverTest.js +++ b/test/serverTest.js @@ -3,77 +3,112 @@ const mc = require('../') const assert = require('power-assert') const { once } = require('events') +const nbt = require('prismarine-nbt') +const applyClientHelpers = require('./common/clientHelpers') const { getPort } = require('./common/util') -const w = { - piglin_safe: { - type: 'byte', - value: 0 - }, - natural: { - type: 'byte', - value: 1 - }, - ambient_light: { - type: 'float', - value: 0 - }, - infiniburn: { - type: 'string', - value: 'minecraft:infiniburn_overworld' - }, - respawn_anchor_works: { - type: 'byte', - value: 0 - }, - has_skylight: { - type: 'byte', - value: 1 - }, - bed_works: { - type: 'byte', - value: 1 - }, - has_raids: { - type: 'byte', - value: 1 - }, - name: { - type: 'string', - value: 'minecraft:overworld' - }, - logical_height: { - type: 'int', - value: 256 - }, - shrunk: { - type: 'byte', - value: 0 - }, - ultrawarm: { - type: 'byte', - value: 0 - }, - has_ceiling: { - type: 'byte', - value: 0 - } -} +const w = nbt.comp({ + piglin_safe: nbt.byte(0), + natural: nbt.byte(1), + ambient_light: nbt.float(0), + infiniburn: nbt.string('minecraft:infiniburn_overworld'), + respawn_anchor_works: nbt.byte(0), + has_skylight: nbt.byte(1), + bed_works: nbt.byte(1), + has_raids: nbt.byte(1), + name: nbt.string('minecraft:overworld'), + logical_height: nbt.int(256), + shrunk: nbt.byte(0), + ultrawarm: nbt.byte(0), + has_ceiling: nbt.byte(0) +}) for (const supportedVersion of mc.supportedVersions) { let PORT const mcData = require('minecraft-data')(supportedVersion) const version = mcData.version - describe('mc-server ' + version.minecraftVersion, function () { + const loginPacket = (client, server) => { + if (mcData.loginPacket) { + return mcData.loginPacket + } + return { + // 1.7 + entityId: client.id, + gameMode: 1, + dimension: (version.version >= 735 ? mcData.loginPacket.dimension : 0), + difficulty: 2, + maxPlayers: server.maxPlayers, + levelType: 'default', + // 1.8 + reducedDebugInfo: (version.version >= 735 ? false : 0), + // 1.14 + // removes `difficulty` + viewDistance: 10, + // 1.15 + hashedSeed: [0, 0], + enableRespawnScreen: true, + // 1.16 + // removed levelType + previousGameMode: version.version >= 755 ? 0 : 255, + worldNames: ['minecraft:overworld'], + dimensionCodec: version.version >= 755 ? mcData.loginPacket.dimensionCodec : (version.version >= 735 ? mcData.loginPacket.dimension : { name: '', type: 'compound', value: { dimension: { type: 'list', value: { type: 'compound', value: [w] } } } }), + worldName: 'minecraft:overworld', + isDebug: false, + isFlat: false, + // 1.16.2 + isHardcore: false, + // 1.18 + simulationDistance: 10, + // 1.19 + // removed `dimension` + // removed `dimensionCodec` + registryCodec: { + type: 'compound', + name: '', + value: {} + }, + worldType: 'minecraft:overworld', + death: undefined, + // 1.20.5 + enforceSecureChat: false + // more to be added + } + } + let nextChatIndex = 1 + + function sendBroadcastMessage (server, clients, message, sender) { + if (mcData.supportFeature('signedChat')) { + server.writeToClients(clients, 'player_chat', { + globalIndex: nextChatIndex++, // 1.21.5+ + plainMessage: message, + signedChatContent: '', + unsignedChatContent: JSON.stringify({ text: message }), + type: mcData.supportFeature('chatTypeIsHolder') ? { chatType: 1 } : 0, + senderUuid: 'd3527a0b-bc03-45d5-a878-2aafdd8c8a43', // random + senderName: JSON.stringify({ text: sender }), + senderTeam: undefined, + timestamp: Date.now(), + salt: 0n, + signature: mcData.supportFeature('useChatSessions') ? undefined : Buffer.alloc(0), + previousMessages: [], + filterType: 0, + networkName: JSON.stringify({ text: sender }) + }) + } else { + server.writeToClients(clients, 'chat', { message: JSON.stringify({ text: message }), position: 0, sender: sender || '0' }) + } + } - this.beforeAll(async function() { + describe('mc-server ' + supportedVersion + 'v', function () { + this.timeout(5000) + this.beforeEach(async function () { + console.log('πŸ”» Starting test', this.currentTest.title) PORT = await getPort() console.log(`Using port for tests: ${PORT}`) }) - this.timeout(5000) it('starts listening and shuts down cleanly', function (done) { const server = mc.createServer({ 'online-mode': false, @@ -90,6 +125,7 @@ for (const supportedVersion of mc.supportedVersions) { done() }) }) + it('kicks clients that do not log in', function (done) { const server = mc.createServer({ 'online-mode': false, @@ -98,29 +134,33 @@ for (const supportedVersion of mc.supportedVersions) { version: version.minecraftVersion, port: PORT }) - let count = 2 + let serverClosed, clientClosed server.on('connection', function (client) { client.on('end', function (reason) { assert.strictEqual(reason, 'LoginTimeout') server.close() }) }) - server.on('close', function () { - resolve() + server.on('close', () => { + serverClosed = true + console.log('Server closed') + checkFinish() }) server.on('listening', function () { const client = new mc.Client(false, version.minecraftVersion) - client.on('end', function () { - resolve() + client.on('end', () => { + clientClosed = true + console.log('Client closed') + checkFinish() }) client.connect(PORT, '127.0.0.1') }) - function resolve () { - count -= 1 - if (count <= 0) done() + function checkFinish () { + if (serverClosed && clientClosed) done() } }) + it('kicks clients that do not send keepalive packets', function (done) { const server = mc.createServer({ 'online-mode': false, @@ -129,15 +169,18 @@ for (const supportedVersion of mc.supportedVersions) { version: version.minecraftVersion, port: PORT }) - let count = 2 + let serverClosed, clientClosed server.on('connection', function (client) { client.on('end', function (reason) { assert.strictEqual(reason, 'KeepAliveTimeout') + console.log('Server client disconnected') server.close() }) }) - server.on('close', function () { - resolve() + server.on('close', () => { + serverClosed = true + console.log('Server closed') + checkFinish() }) server.on('listening', function () { const client = mc.createClient({ @@ -147,16 +190,65 @@ for (const supportedVersion of mc.supportedVersions) { keepAlive: false, version: version.minecraftVersion }) - client.on('end', function () { - resolve() + client.on('end', () => { + clientClosed = true + console.log('Client closed') + checkFinish() }) }) - function resolve () { - count -= 1 - if (count <= 0) done() + function checkFinish () { + if (serverClosed && clientClosed) done() } }) + it('responds to ping requests', function (done) { + const chatMotd = { // Generated with prismarine-chat MessageBuilder on version 1.16 may change in the future + extra: [{ color: 'red', text: 'Red text' }], + bold: true, + text: 'Example chat mesasge' + } + + const server = mc.createServer({ + 'online-mode': false, + motd: 'test1234', + motdMsg: chatMotd, + 'max-players': 120, + version: version.minecraftVersion, + port: PORT + }) + server.on('listening', function () { + mc.ping({ + host: '127.0.0.1', + version: version.minecraftVersion, + port: PORT + }, function (err, results) { + if (err) return done(err) + assert.ok(results.latency >= 0) + assert.ok(results.latency <= 1000) + delete results.latency + assert.deepEqual(results, { + version: { + name: version.minecraftVersion, + protocol: version.version + }, + players: { + max: 120, + online: 0, + sample: [] + }, + description: { + extra: [{ color: 'red', text: 'Red text' }], + bold: true, + text: 'Example chat mesasge' + } + }) + server.close() + }) + }) + server.on('close', done) + }) + + it('responds with chatMessage motd\'s', function (done) { const server = mc.createServer({ 'online-mode': false, motd: 'test1234', @@ -191,6 +283,7 @@ for (const supportedVersion of mc.supportedVersions) { }) server.on('close', done) }) + it('clients can be changed by beforeLogin', function (done) { const notchUUID = '069a79f4-44e9-4726-a5be-fca90e38aaf5' const server = mc.createServer({ @@ -208,7 +301,7 @@ for (const supportedVersion of mc.supportedVersions) { version: version.minecraftVersion, port: PORT }) - client.on('packet', (data, {name})=>{ + client.on('packet', (data, { name }) => { if (name === 'success') { assert.strictEqual(data.uuid, notchUUID, 'UUID') server.close() @@ -217,15 +310,20 @@ for (const supportedVersion of mc.supportedVersions) { }) server.on('close', done) }) + it('clients can log in and chat', function (done) { const server = mc.createServer({ 'online-mode': false, version: version.minecraftVersion, port: PORT }) + const broadcast = (message, exclude) => sendBroadcastMessage(server, + Object.values(server.clients).filter(client => client !== exclude), message) + const username = ['player1', 'player2'] let index = 0 - server.on('login', function (client) { + server.on('playerJoin', function (client) { + console.log('ChatTest: Player has joined') assert.notEqual(client.id, null) assert.strictEqual(client.username, username[index++]) broadcast(client.username + ' joined the game.') @@ -233,158 +331,117 @@ for (const supportedVersion of mc.supportedVersions) { broadcast(client.username + ' left the game.', client) if (client.username === 'player2') server.close() }) - const loginPacket = { - entityId: client.id, - levelType: 'default', - gameMode: 1, - previousGameMode: version.version >= 755 ? 0 : 255, - worldNames: ['minecraft:overworld'], - dimensionCodec: version.version >= 755 ? mcData.loginPacket.dimensionCodec : (version.version >= 735 ? mcData.loginPacket.dimension : { name: '', type: 'compound', value: { dimension: { type: 'list', value: { type: 'compound', value: [w] } } } }), - dimension: (version.version >= 735 ? mcData.loginPacket.dimension : 0), - worldName: 'minecraft:overworld', - hashedSeed: [0, 0], - difficulty: 2, - maxPlayers: server.maxPlayers, - reducedDebugInfo: (version.version >= 735 ? false : 0), - enableRespawnScreen: true - } - if (version.version >= 735) { // 1.16x - loginPacket.isDebug = false - loginPacket.isFlat = false - loginPacket.isHardcore = false - loginPacket.viewDistance = 10 - delete loginPacket.levelType - delete loginPacket.difficulty - } - client.write('login', loginPacket) - client.on('chat', function (packet) { - const message = '<' + client.username + '>' + ' ' + packet.message - broadcast(message) - }) + client.write('login', loginPacket(client, server)) + + const handleChat = (packet) => broadcast(`<${client.username}> ${packet.message}`) + client.on('chat', handleChat) + client.on('chat_message', handleChat) }) server.on('close', done) + server.on('listening', function () { - const player1 = mc.createClient({ + const player1 = applyClientHelpers(mc.createClient({ username: 'player1', host: '127.0.0.1', version: version.minecraftVersion, port: PORT - }) - player1.on('login', function (packet) { - assert.strictEqual(packet.gameMode, 1) - player1.once('chat', function (packet) { - assert.strictEqual(packet.message, '{"text":"player2 joined the game."}') - player1.once('chat', function (packet) { - assert.strictEqual(packet.message, '{"text":" hi"}') - player2.once('chat', fn) - function fn (packet) { - if (//.test(packet.message)) { - player2.once('chat', fn) - return - } - assert.strictEqual(packet.message, '{"text":" hello"}') - player1.once('chat', function (packet) { - assert.strictEqual(packet.message, '{"text":"player2 left the game."}') - player1.end() - }) - player2.end() - } - - player1.write('chat', { message: 'hello' }) - }) - player2.write('chat', { message: 'hi' }) - }) - const player2 = mc.createClient({ + })) + console.log('ChatTest: Player1 is joining...') + + player1.on('login', async function (packet) { + console.log('ChatTest: Player 1 has joined') + const player2 = applyClientHelpers(mc.createClient({ username: 'player2', host: '127.0.0.1', version: version.minecraftVersion, port: PORT - }) - }) - }) + })) - function broadcast (message, exclude) { - let client - for (const clientId in server.clients) { - if (server.clients[clientId] === undefined) continue + console.log('ChatTest: waiting for next message from P2') + const p1Join = await player1.nextMessage('player2') - client = server.clients[clientId] - if (client !== exclude) client.write('chat', { message: JSON.stringify({ text: message }), position: 0, sender: '0' }) - } - } + assert.strictEqual(p1Join, '{"text":"player2 joined the game."}') + console.log('ChatTest: Got message from P2') + player2.chat('hi') + const p2hi = await player1.nextMessage('player2') + assert.strictEqual(p2hi, '{"text":" hi"}') + + console.log('ChatTest: Waiting again for next message from P2') + player1.chat('hello') + const p1hello = await player2.nextMessage('player1') + assert.strictEqual(p1hello, '{"text":" hello"}') + + player2.end() + const p2leaving = await player1.nextMessage('player2') + assert.strictEqual(p2leaving, '{"text":"player2 left the game."}') + player1.end() + }) + }) }) + it('kicks clients when invalid credentials', function (done) { this.timeout(10000) const server = mc.createServer({ version: version.minecraftVersion, port: PORT }) - let count = 4 + let serverPlayerDisconnected, serverClosed, clientClosed server.on('connection', function (client) { client.on('end', function (reason) { - resolve() + serverPlayerDisconnected = true + console.log('Server player disconnected') + checkFinish() server.close() }) }) - server.on('close', function () { - resolve() + server.on('close', () => { + serverClosed = true + console.log('Server closed') + checkFinish() }) server.on('listening', function () { - resolve() + console.log('Server is listening') const client = mc.createClient({ username: 'lalalal', host: '127.0.0.1', version: version.minecraftVersion, port: PORT }) - client.on('end', function () { - resolve() + client.on('end', () => { + clientClosed = true + console.log('Client closed') + checkFinish() }) }) - function resolve () { - count -= 1 - if (count <= 0) done() + function checkFinish () { + if (serverPlayerDisconnected && clientClosed && serverClosed) { + console.log('Kick test is done') + callOnce(done) + } } - }) + }).retries(2) + it('gives correct reason for kicking clients when shutting down', function (done) { const server = mc.createServer({ 'online-mode': false, version: version.minecraftVersion, port: PORT }) - let count = 2 - server.on('login', function (client) { + let serverPlayerDisconnected, serverClosed + server.on('playerJoin', function (client) { + console.log('Server got player join') client.on('end', function (reason) { assert.strictEqual(reason, 'ServerShutdown') - resolve() + serverPlayerDisconnected = true + console.log('Server player disconnected') + checkFinish() }) - const loginPacket = { - entityId: client.id, - levelType: 'default', - gameMode: 1, - previousGameMode: version.version >= 755 ? 0 : 255, - worldNames: ['minecraft:overworld'], - dimensionCodec: version.version >= 755 ? mcData.loginPacket.dimensionCodec : (version.version >= 735 ? mcData.loginPacket.dimension : { name: '', type: 'compound', value: { dimension: { type: 'list', value: { type: 'compound', value: [w] } } } }), - dimension: (version.version >= 735 ? mcData.loginPacket.dimension : 0), - worldName: 'minecraft:overworld', - hashedSeed: [0, 0], - difficulty: 2, - maxPlayers: server.maxPlayers, - reducedDebugInfo: (version.version >= 735 ? false : 0), - enableRespawnScreen: true - } - if (version.version >= 735) { // 1.16x - loginPacket.isDebug = false - loginPacket.isFlat = false - loginPacket.isHardcore = false - loginPacket.viewDistance = 10 - delete loginPacket.levelType - delete loginPacket.difficulty - } - client.write('login', loginPacket) + client.write('login', loginPacket(client, server)) }) - server.on('close', function () { - resolve() + server.on('close', () => { + serverClosed = true + console.log('Server closed') + checkFinish() }) server.on('listening', function () { const client = mc.createClient({ @@ -393,72 +450,98 @@ for (const supportedVersion of mc.supportedVersions) { version: version.minecraftVersion, port: PORT }) - client.on('login', function () { + client.on('playerJoin', function () { + console.log('Client joined') server.close() }) }) - function resolve () { - count -= 1 - if (count <= 0) done() + function checkFinish () { + if (serverPlayerDisconnected && serverClosed) done() } }) + it('encodes chat packet once and send it to two clients', function (done) { const server = mc.createServer({ 'online-mode': false, version: version.minecraftVersion, port: PORT }) - server.on('login', function (client) { - const loginPacket = { - entityId: client.id, - levelType: 'default', - gameMode: 1, - previousGameMode: version.version >= 755 ? 0 : 255, - worldNames: ['minecraft:overworld'], - dimensionCodec: version.version >= 755 ? mcData.loginPacket.dimensionCodec : (version.version >= 735 ? mcData.loginPacket.dimension : { name: '', type: 'compound', value: { dimension: { type: 'list', value: { type: 'compound', value: [w] } } } }), - dimension: (version.version >= 735 ? mcData.loginPacket.dimension : 0), - worldName: 'minecraft:overworld', - hashedSeed: [0, 0], - difficulty: 2, - maxPlayers: server.maxPlayers, - reducedDebugInfo: (version.version >= 735 ? false : 0), - enableRespawnScreen: true - } - if (version.version >= 735) { // 1.16x - loginPacket.isDebug = false - loginPacket.isFlat = false - loginPacket.isHardcore = false - loginPacket.viewDistance = 10 - delete loginPacket.levelType - delete loginPacket.difficulty - } - client.write('login', loginPacket) + server.on('playerJoin', function (client) { + client.write('login', loginPacket(client, server)) }) server.on('close', done) server.on('listening', async function () { - const player1 = mc.createClient({ + const player1 = applyClientHelpers(mc.createClient({ username: 'player1', host: '127.0.0.1', version: version.minecraftVersion, port: PORT - }) - const player2 = mc.createClient({ + })) + const player2 = applyClientHelpers(mc.createClient({ username: 'player2', host: '127.0.0.1', version: version.minecraftVersion, port: PORT - }) - await Promise.all([once(player1, 'login'), once(player2, 'login')]) - server.writeToClients(Object.values(server.clients), 'chat', { message: JSON.stringify({ text: 'A message from the server.' }), position: 1, sender: '00000000-0000-0000-0000-000000000000' }) - - let results = await Promise.all([ once(player1, 'chat'), once(player2, 'chat') ]) - results.forEach(res => assert.strictEqual(res[0].message, '{"text":"A message from the server."}')) - + })) + await Promise.all([once(player1, 'login'), once(player2, 'login')]) + + sendBroadcastMessage(server, Object.values(server.clients), 'A message from the server.') + + const results = await Promise.all([player1.nextMessage(), player2.nextMessage()]) + for (const msg of results) { + assert.strictEqual(msg, '{"text":"A message from the server."}') + } + player1.end() player2.end() await Promise.all([once(player1, 'end'), once(player2, 'end')]) server.close() }) }) + + it('supports bundle packet', function (done) { + const server = mc.createServer({ + 'online-mode': false, + version: version.minecraftVersion, + port: PORT + }) + server.on('playerJoin', function (client) { + client.on('end', function (reason) { + assert.strictEqual(reason, 'ServerShutdown') + }) + client.write('login', loginPacket(client, server)) + client.writeBundle([ + ['update_time', { age: 1, time: 2 }], + ['close_window', { windowId: 0 }] + ]) + }) + server.on('close', done) + server.on('listening', function () { + const client = mc.createClient({ + username: 'lalalal', + host: '127.0.0.1', + version: version.minecraftVersion, + port: PORT + }) + client.on('update_time', function () { + // Below handler synchronously defined should be guaranteed to be called after the above one + const d1 = Date.now() + client.on('close_window', function () { + server.close() + const d2 = Date.now() + if (mcData.supportFeature('hasBundlePacket') && (d2 - d1) > 1) { + throw new Error(`bundle packet constituents did not arrive at once : ${d1}, ${d2}`) + } + }) + }) + }) + }) }) } + +function callOnce (fn, ...args) { + console.log('Call Fn', fn.called) + if (fn.called) return + fn(...args) + fn.called = true +}