UNPKG

minecraft-protocol

Version:

Parse and serialize minecraft packets, plus authentication and encryption.

251 lines (226 loc) 9.39 kB
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 = function (client, err) { if (!options.hideErrors) console.debug('Disconnecting client because error', err) client.end(err) } } = options let serverId client.on('error', function (err) { clientErrorHandler(client, err) }) 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) const publicKeyStrArr = server.serverKey.exportKey('pkcs8-public-pem').split('\n') let publicKeyStr = '' for (let i = 1; i < publicKeyStrArr.length - 1; i++) { publicKeyStr += publicKeyStrArr[i] } client.publicKey = Buffer.from(publicKeyStr, 'base64') client.once('encryption_begin', onEncryptionKeyResponse) client.write('encryption_begin', { serverId, publicKey: client.publicKey, verifyToken: client.verifyToken, shouldAuthenticate: true }) } else { loginClient() } } function onEncryptionKeyResponse (packet) { 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 } } let sharedSecret try { sharedSecret = keyRsa.decrypt(packet.sharedSecret) } catch (e) { client.end('DidNotEncryptVerifyTokenProperly') return } client.setEncryption(sharedSecret) const isException = !!server.onlineModeExceptions[client.username.toLowerCase()] const needToVerify = (onlineMode && !isException) || (!onlineMode && isException) const nextStep = needToVerify ? verifyUsername : loginClient nextStep() function verifyUsername () { yggdrasilServer.hasJoined(client.username, serverId, sharedSecret, client.publicKey, function (err, profile) { if (err) { client.end('Failed to verify username!') return } // Convert to a valid UUID until the session server updates and does // it automatically client.uuid = profile.id.replace(/(\w{8})(\w{4})(\w{4})(\w{4})(\w{12})/, '$1-$2-$3-$4-$5') client.username = profile.name client.profile = profile loginClient() }) } } function loginClient () { const isException = !!server.onlineModeExceptions[client.username.toLowerCase()] if (onlineMode === false || isException) { 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 } // 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 server.emit('playerJoin', client) } 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 server.playerCount += 1 client.once('end', function () { server.playerCount -= 1 }) pluginChannels(client, options) if (client.supportFeature('signedChat')) chatPlugin(client, server, options) server.emit('login', 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 }