1.19.0 support (#1027)

* Bump mcdata for 1.19

* 1.19 in version.js

* 1.19.0 in ci yml

* Update ci.yml

* Update version.js

* Update package.json

* No fail fast

* Update mcdata

* Update package.json

* Update ci.yml

* [1.19] fix tests and library session code (#1020)

* make tests work, add todo's

* clean up, varlong test, additional todo

* removed log statements, fix for older versions

* Update mcdata

* Update ci.yml

* Update ci.yml

* remove excessive version comments near supportFeature checks

Co-authored-by: Romain Beaumont <romain.rom1@gmail.com>

* chat signing implementation

* Update ci.yml

* move some boilerplate to pauth

* update tests

* update chat example

* bump pauth, update doc

* modify test nextMessage func

* lint

* update default version

* add server player verifyMessage

* update doc

Co-authored-by: Romain Beaumont <romain.rom1@gmail.com>
Co-authored-by: Rob9315 <dev.robk@gmail.com>
This commit is contained in:
extremeheat 2022-08-15 18:57:26 -04:00 committed by GitHub
parent 60379eb7d2
commit d7c5053a13
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 494 additions and 314 deletions

View file

@ -13,7 +13,8 @@ jobs:
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.2']
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.2', '1.19']
fail-fast: false
steps:
- uses: actions/checkout@v2

View file

@ -31,6 +31,7 @@ automatically logged in and validated against mojang's auth.
* 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)
* 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+)
## mc.Server(version,[customPackets])
@ -40,6 +41,10 @@ Create a server instance for `version` of minecraft.
Write a packet to all `clients` but encode it only once.
### client.verifyMessage(packet) : boolean
Verifies if player's chat message packet was signed with their Mojang provided key
### server.onlineModeExceptions
This is a plain old JavaScript object. Add a key with the username you want to
@ -129,7 +134,7 @@ Returns a `Client` instance and perform login.
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+)
## mc.Client(isServer,version,[customPackets])
@ -268,6 +273,13 @@ 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.signMessage(message: string, timestamp: BigInt, salt?: number) : Buffer
Generate a signature for a chat message to be sent to server
### client.verifyMessage(publicKey: Buffer | KeyObject, packet) : boolean
Verifies a player chat packet sent by another player against their public key
## Not Immediately Obvious Data Type Formats

View file

@ -1,66 +1,28 @@
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
})
function printHelp () {
console.log('usage: node client_chat.js <hostname> <port> <user> [<password>]')
}
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 <host> <port> <username>')
console.error('Usage (offline mode): node client_chat.js <host> <port> 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 +31,66 @@ 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 mcData = require('minecraft-data')(client.version)
const ChatMessage = require('prismarine-chat')(client.version)
const players = {} // 1.19+
console.log('Connected to server')
client.chat = (message) => {
if (mcData.supportFeature('signedChat')) {
const timestamp = BigInt(Date.now())
client.write('chat_message', {
message,
timestamp,
salt: 0,
signature: client.signMessage(message, timestamp)
})
} else {
client.write('chat', { message })
}
}
function onChat (packet) {
const message = packet.message || packet.unsignedChatContent || packet.signedChatContent
const j = JSON.parse(message)
const chat = new ChatMessage(j)
if (packet.signature) {
const verified = client.verifyMessage(players[packet.senderUuid].publicKey, packet)
console.info(verified ? 'Verified: ' : 'UNVERIFIED: ', chat.toAnsi())
} else {
console.info(chat.toAnsi())
}
}
client.on('chat', onChat)
client.on('player_chat', onChat)
client.on('player_info', (packet) => {
if (packet.action === 0) { // add player
for (const player of packet.data) {
players[player.UUID] = player.crypto
}
}
})
})
// 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 +102,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())
})

View file

@ -51,11 +51,11 @@
"endian-toggle": "^0.0.0",
"lodash.get": "^4.1.2",
"lodash.merge": "^4.3.0",
"minecraft-data": "^3.0.0",
"minecraft-data": "^3.8.0",
"minecraft-folder-path": "^1.2.0",
"node-fetch": "^2.6.1",
"node-rsa": "^0.4.2",
"prismarine-auth": "^1.1.0",
"prismarine-auth": "^1.7.0",
"prismarine-nbt": "^2.0.0",
"protodef": "^1.8.0",
"readable-stream": "^4.1.0",

View file

@ -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)
}
}

View file

@ -14,7 +14,7 @@ async function authenticate (client, options) {
}
const Authflow = new PrismarineAuth(options.username, options.profilesFolder, options, options.onMsaCode)
const { token, entitlements, profile } = await Authflow.getMinecraftJavaToken({ fetchProfile: true }).catch(e => {
const { token, entitlements, profile, certificates } = await 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,8 +32,10 @@ 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)

View file

@ -1,4 +1,6 @@
const states = require('../states')
const crypto = require('crypto')
const concat = require('../transforms/binaryStream').concat
module.exports = function (client, options) {
client.once('success', onLogin)
@ -7,5 +9,17 @@ module.exports = function (client, options) {
client.state = states.PLAY
client.uuid = packet.uuid
client.username = packet.username
client.signMessage = (message, timestamp, salt = 0) => {
if (!client.profileKeys) throw Error("Can't sign message without profile keys, please set valid auth mode")
const signable = concat('i64', salt, 'UUID', client.uuid, 'i64',
timestamp / 1000n, 'pstring', JSON.stringify({ text: message }))
return crypto.sign('RSA-SHA256', signable, client.profileKeys.private)
}
client.verifyMessage = (pubKey, packet) => {
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)
}
}
}

View file

@ -25,7 +25,14 @@ module.exports = function (client, options) {
})
client.state = states.LOGIN
client.write('login_start', {
username: client.username
username: client.username,
signature: client.profileKeys
? {
timestamp: BigInt(client.profileKeys.expiresOn.getTime()), // should probably be called "expireTime"
publicKey: client.profileKeys.publicDER,
signature: client.profileKeys.signature
}
: null
})
}
}

10
src/index.d.ts vendored
View file

@ -5,6 +5,7 @@ import { Socket } from 'net'
import * as Stream from 'stream'
import { Agent } from 'http'
import { Transform } from "readable-stream";
import { KeyObject } from 'crypto';
type PromiseLike = Promise<void> | void
@ -34,6 +35,8 @@ 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): Buffer
verifyMessage(publicKey: Buffer | KeyObject, packet: object): boolean
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
@ -125,7 +128,9 @@ declare module 'minecraft-protocol' {
onMsaCode?: (data: MicrosoftDeviceAuthorizationResponse) => void
id?: number
session?: SessionOption
validateChannelProtocol?: boolean
validateChannelProtocol?: boolean,
// 1.19+
disableChatSigning: boolean
}
export class Server extends EventEmitter {
@ -173,6 +178,9 @@ declare module 'minecraft-protocol' {
hideErrors?: boolean
agent?: Agent
validateChannelProtocol?: boolean
// 1.19+
// Require connecting clients to have chat signing support enabled
enforceSecureProfile: boolean
}
export interface SerializerOptions {

3
src/server/constants.js Normal file
View file

@ -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-----'
}

View file

@ -1,11 +1,14 @@
const UUID = require('uuid-1345')
const bufferEqual = require('buffer-equal')
const crypto = require('crypto')
const pluginChannels = require('../client/pluginChannels')
const states = require('../states')
const yggdrasil = require('yggdrasil')
const { concat } = require('../transforms/binaryStream')
const { mojangPublicKeyPem } = require('./constants')
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,
@ -21,15 +24,49 @@ 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.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())) {
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 publicPEM = mcPubKeyToPem(packet.signature.publicKey)
const signable = packet.signature.timestamp + publicPEM // (expires at + publicKey)
if (!crypto.verify('RSA-SHA1', Buffer.from(signable, 'utf8'), mojangPubKey, packet.signature.signature)) {
raise('multiplayer.disconnect.invalid_public_key_signature')
return
}
client.profileKeys = { public: publicKey, publicPEM }
} catch (err) {
raise('multiplayer.disconnect.invalid_public_key')
return
}
}
if (needToVerify) {
serverId = crypto.randomBytes(4).toString('hex')
client.verifyToken = crypto.randomBytes(4)
@ -41,7 +78,7 @@ 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
})
@ -50,23 +87,51 @@ module.exports = function (client, server, options) {
}
}
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
}
}
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 = crypto.privateDecrypt({
key: server.serverKey.exportKey(),
padding: crypto.constants.RSA_PKCS1_PADDING
}, 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 = crypto.privateDecrypt({
key: server.serverKey.exportKey(),
padding: crypto.constants.RSA_PKCS1_PADDING
}, packet.sharedSecret)
} catch (e) {
client.end('DidNotEncryptVerifyTokenProperly')
return
}
client.setEncryption(sharedSecret)
const isException = !!server.onlineModeExceptions[client.username.toLowerCase()]
@ -114,7 +179,12 @@ module.exports = function (client, server, options) {
client.write('compress', { threshold: 256 }) // Default threshold is 256
client.compressionThreshold = 256
}
client.write('success', { uuid: client.uuid, username: client.username })
client.write('success', {
uuid: client.uuid,
username: client.username,
properties: []
})
// TODO: find out what properties are on 'success' packet
client.state = states.PLAY
clearTimeout(loginKickTimer)
@ -125,6 +195,27 @@ module.exports = function (client, server, options) {
server.playerCount -= 1
})
pluginChannels(client, options)
if (client.profileKeys) {
client.verifyMessage = (packet) => {
const signable = concat('i64', packet.salt, 'UUID', client.uuid, 'i64',
packet.timestamp, 'pstring', packet.message)
return crypto.verify('sha256WithRSAEncryption', signable, client.profileKeys.public, packet.crypto.signature)
}
}
server.emit('login', client)
}
}
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
}

View file

@ -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) => <Buffer 00 00 00 16 00 00 00 00 00 00 00 02>
module.exports = { concat }

View file

@ -1,6 +1,6 @@
'use strict'
module.exports = {
defaultVersion: '1.18.2',
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.2']
defaultVersion: '1.19',
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.2', '1.19']
}

View file

@ -7,7 +7,8 @@ const states = mc.states
const testDataWrite = [
{ name: 'keep_alive', params: { keepAliveId: 957759560 } },
{ name: 'chat', params: { message: '<Bob> Hello World!' } },
// TODO: 1.19+ `chat` -> `player_chat` feature toggle
// { name: 'chat', params: { message: '<Bob> 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
]

View file

@ -4,16 +4,16 @@ const mc = require('../')
const os = require('os')
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)
@ -97,12 +97,12 @@ for (const supportedVersion of mc.supportedVersions) {
})
it('connects successfully - offline mode', function (done) {
const client = mc.createClient({
const client = applyClientHelpers(mc.createClient({
username: 'Player',
version: version.minecraftVersion,
port: PORT,
auth: 'offline'
})
}))
client.on('error', err => done(err))
const lineListener = function (line) {
const match = line.match(/\[Server thread\/INFO\]: <(.+?)> (.+)/)
@ -110,15 +110,16 @@ for (const supportedVersion of mc.supportedVersions) {
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.'
})
client.chat('hello everyone; I have logged in.')
})
// 1.18 and below
client.on('chat', function (packet) {
chatCount += 1
assert.ok(chatCount <= 2)
@ -144,20 +145,34 @@ for (const supportedVersion of mc.supportedVersions) {
done()
}
})
// 1.19 and above
let gotClientMessage, gotServerMessage
client.on('player_chat', (packet) => {
const message = JSON.parse(packet.unsignedChatContent || packet.signedChatContent)
// const sender = JSON.parse(packet.senderName)
if (message.text === 'hello everyone; I have logged in.') gotClientMessage = true
if (message.text === 'hello') gotServerMessage = true
if (gotClientMessage && gotServerMessage) {
wrap.removeListener('line', lineListener)
client.end()
done()
}
})
})
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,
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()
@ -166,6 +181,7 @@ 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',
@ -214,13 +230,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,
auth: 'offline'
})
port: PORT
}))
client.on('error', err => done(err))
const lineListener = function (line) {
const match = line.match(/\[Server thread\/INFO\]: <(.+?)> (.+)/)
@ -235,9 +250,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) {

View file

@ -0,0 +1,39 @@
module.exports = client => {
const mcData = require('minecraft-data')(client.version)
const hasSignedChat = mcData.supportFeature('signedChat')
client.chat = (message) => {
if (hasSignedChat) {
const timestamp = BigInt(Date.now())
client.write('chat_message', {
message,
timestamp,
salt: 0,
signature: Buffer.alloc(0)
})
} else {
client.write('chat', { message })
}
}
client.nextMessage = (containing) => {
return new Promise((resolve) => {
function onChat (packet) {
const m = packet.message || packet.unsignedChatContent || packet.signedChatContent
if (containing) {
if (m.includes(containing)) return finish(m)
else return
}
return finish(m)
}
client.on(hasSignedChat ? 'player_chat' : 'chat', onChat)
function finish (m) {
client.off(hasSignedChat ? 'player_chat' : 'chat', onChat)
resolve(m)
}
})
}
return client
}

View file

@ -40,7 +40,7 @@ const values = {
i16: -123,
u16: 123,
varint: 1,
varlong: -10,
varlong: -20,
i8: -10,
u8: 8,
string: 'hi hi this is my client string',

View file

@ -3,77 +3,100 @@
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) => {
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
// more to be added
}
}
function sendBroadcastMessage(server, clients, message, sender) {
if (mcData.supportFeature('signedChat')) {
server.writeToClients(clients, 'player_chat', {
signedChatContent: '',
unsignedChatContent: JSON.stringify({ text: message }),
type: 0,
senderUuid: 'd3527a0b-bc03-45d5-a878-2aafdd8c8a43', // random
senderName: JSON.stringify({ text: sender }),
senderTeam: undefined,
timestamp: Date.now(),
salt: 0n,
signature: Buffer.alloc(0)
})
} else {
server.writeToClients(clients, 'chat', { message: JSON.stringify({ text: message }), position: 0, sender: sender || '0' })
}
}
describe('mc-server ' + version.minecraftVersion, function () {
this.timeout(5000)
this.beforeAll(async function() {
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 +113,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,
@ -105,14 +129,10 @@ for (const supportedVersion of mc.supportedVersions) {
server.close()
})
})
server.on('close', function () {
resolve()
})
server.on('close', resolve)
server.on('listening', function () {
const client = new mc.Client(false, version.minecraftVersion)
client.on('end', function () {
resolve()
})
client.on('end', resolve)
client.connect(PORT, '127.0.0.1')
})
@ -121,6 +141,7 @@ for (const supportedVersion of mc.supportedVersions) {
if (count <= 0) done()
}
})
it('kicks clients that do not send keepalive packets', function (done) {
const server = mc.createServer({
'online-mode': false,
@ -136,9 +157,7 @@ for (const supportedVersion of mc.supportedVersions) {
server.close()
})
})
server.on('close', function () {
resolve()
})
server.on('close', resolve)
server.on('listening', function () {
const client = mc.createClient({
username: 'superpants',
@ -147,15 +166,14 @@ for (const supportedVersion of mc.supportedVersions) {
keepAlive: false,
version: version.minecraftVersion
})
client.on('end', function () {
resolve()
})
client.on('end', resolve)
})
function resolve () {
count -= 1
if (count <= 0) 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' }],
@ -202,6 +220,7 @@ for (const supportedVersion of mc.supportedVersions) {
})
server.on('close', done)
})
it('responds with chatMessage motd\'s', function (done) {
const server = mc.createServer({
'online-mode': false,
@ -237,6 +256,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({
@ -263,12 +283,16 @@ 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) {
@ -279,86 +303,49 @@ 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) {
}))
player1.on('login', async 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":"<player2> hi"}')
player2.once('chat', fn)
function fn (packet) {
if (/<player2>/.test(packet.message)) {
player2.once('chat', fn)
return
}
assert.strictEqual(packet.message, '{"text":"<player1> 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({
const player2 = applyClientHelpers(mc.createClient({
username: 'player2',
host: '127.0.0.1',
version: version.minecraftVersion,
port: PORT
})
}))
const p1Join = await player1.nextMessage('player2')
assert.strictEqual(p1Join, '{"text":"player2 joined the game."}')
player2.chat('hi')
const p2hi = await player1.nextMessage('player2')
assert.strictEqual(p2hi, '{"text":"<player2> hi"}')
player1.chat('hello')
const p1hello = await player2.nextMessage('player1')
assert.strictEqual(p1hello, '{"text":"<player1> hello"}')
player2.end()
const p2leaving = await player1.nextMessage('player2')
assert.strictEqual(p2leaving, '{"text":"player2 left the game."}')
player1.end()
})
})
function broadcast (message, exclude) {
let client
for (const clientId in server.clients) {
if (server.clients[clientId] === undefined) continue
client = server.clients[clientId]
if (client !== exclude) client.write('chat', { message: JSON.stringify({ text: message }), position: 0, sender: '0' })
}
}
})
it('kicks clients when invalid credentials', function (done) {
this.timeout(10000)
const server = mc.createServer({
@ -372,9 +359,7 @@ for (const supportedVersion of mc.supportedVersions) {
server.close()
})
})
server.on('close', function () {
resolve()
})
server.on('close', resolve)
server.on('listening', function () {
resolve()
const client = mc.createClient({
@ -383,15 +368,14 @@ for (const supportedVersion of mc.supportedVersions) {
version: version.minecraftVersion,
port: PORT
})
client.on('end', function () {
resolve()
})
client.on('end', resolve)
})
function resolve () {
count -= 1
if (count <= 0) done()
}
})
it('gives correct reason for kicking clients when shutting down', function (done) {
const server = mc.createServer({
'online-mode': false,
@ -404,34 +388,9 @@ for (const supportedVersion of mc.supportedVersions) {
assert.strictEqual(reason, 'ServerShutdown')
resolve()
})
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('close', function () {
resolve()
client.write('login', loginPacket(client, server))
})
server.on('close', resolve)
server.on('listening', function () {
const client = mc.createClient({
username: 'lalalal',
@ -448,6 +407,7 @@ for (const supportedVersion of mc.supportedVersions) {
if (count <= 0) done()
}
})
it('encodes chat packet once and send it to two clients', function (done) {
const server = mc.createServer({
'online-mode': false,
@ -455,50 +415,30 @@ for (const supportedVersion of mc.supportedVersions) {
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)
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."}'))
sendBroadcastMessage(server, Object.values(server.clients), 'A message from the server.')
let 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()