From cf1f67117d586b5e6e21f0d9602da12e9fcf46b6 Mon Sep 17 00:00:00 2001 From: Frej Alexander Nielsen Date: Sat, 21 Jan 2023 20:31:17 +0100 Subject: [PATCH] Update to 1.19.3 (#1069) * Add 1.19.3 player_info parsing * player_remove packet parsing * 1.19.3 chat parsing * Outgoing chat for 1.19.3 * Fix lint * Server chat validation * add 1.19.2 and 1.19.3 in version.js * Add 1.19.2 and 1.19.3 in ci.yml * Deprecated client.verifyMessage for server clients * Update docs * Deprecate client.verifyMessage for server clients * Fix tests * Fix lint * Fix packetTest * Fix test * Remove unneeded log statement * Update types/docs * Remove unnecessary feature check * Remove _session from docs Co-authored-by: Romain Beaumont --- .github/workflows/ci.yml | 2 +- docs/API.md | 4 +- src/client/chat.js | 309 ++++++++++++++++++++++++++++------- src/client/play.js | 15 ++ src/client/setProtocol.js | 3 +- src/index.d.ts | 2 +- src/server/chat.js | 108 +++++++++++- src/server/login.js | 2 +- src/version.js | 4 +- test/clientTest.js | 75 ++++++--- test/common/clientHelpers.js | 11 +- test/packetTest.js | 16 +- test/serverTest.js | 8 +- 13 files changed, 461 insertions(+), 98 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7c44587..4de44f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ 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', '1.19'] + 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', '1.19.2', '1.19.3'] fail-fast: false steps: diff --git a/docs/API.md b/docs/API.md index 229566e..7010837 100644 --- a/docs/API.md +++ b/docs/API.md @@ -44,7 +44,7 @@ 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 +(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 @@ -306,7 +306,7 @@ 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) : Buffer +### 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 diff --git a/src/client/chat.js b/src/client/chat.js index 95800b2..3332376 100644 --- a/src/client/chat.js +++ b/src/client/chat.js @@ -22,8 +22,13 @@ module.exports = function (client, options) { client._lastChatSignature = null client._lastRejectedMessage = null - // This stores the last 5 messages that the player has seen, from unique players - client._lastSeenMessages = new LastSeenMessages() + // 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() + + // 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 @@ -35,6 +40,31 @@ module.exports = function (client, options) { } }() + 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 + } else { + player.hasChainIntegrity = false + } + + if (player.hasChainIntegrity) { + const length = Buffer.byteLength(message, 'utf8') + const acknowledgements = previousMessages.length > 0 ? ['i32', previousMessages.length, 'buffer', Buffer.concat(...previousMessages.map(msg => msg.signature || client._signatureCache[msg.id]))] : ['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] @@ -63,7 +93,30 @@ module.exports = function (client, options) { return false } + client.on('player_remove', (packet) => { + for (const player of packet.players) { + delete client._players[player.UUID] + } + }) + client.on('player_info', (packet) => { + if (mcData.supportFeature('playerInfoActionIsBitfield')) { // 1.19.3+ + if (packet.action & 2) { // chat session + for (const player of packet.data) { + if (!player.chatSession) continue + 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 + } + } + + return + } + if (packet.action === 0) { // add player for (const player of packet.data) { if (player.crypto) { @@ -83,88 +136,163 @@ module.exports = function (client, options) { } }) + 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: packet.message, + type: packet.type, + senderName: packet.name, + targetName: 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: packet.content }) + + client._lastChatHistory.push({ + type: 2, // System message + message: { + decorated: packet.content + }, + timestamp: Date.now() + }) }) client.on('message_header', (packet) => { 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('chainedChatWithHashing')) { // 1.19.0 - const pubKey = client._players[packet.senderUuid]?.publicKey + 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 client.emit('playerChat', { - formattedMessage: packet.signedChatContent || packet.unsignedChatContent, + plainMessage: packet.plainMessage, + unsignedContent: packet.unsignedContent, type: packet.type, sender: packet.senderUuid, - senderName: packet.senderName, - senderTeam: packet.senderTeam, - verified: (pubKey && !packet.unsignedChatContent) ? client.verifyMessage(pubKey, packet) : false + senderName: packet.networkName, + targetName: 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 } - 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) + 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.bodyDigest, + 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 } - // 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 + const pubKey = client._players[packet.senderUuid]?.publicKey client.emit('playerChat', { - plainMessage: packet.plainMessage, - unsignedContent: packet.unsignedChatContent, - formattedMessage: packet.formattedMessage, + formattedMessage: packet.signedChatContent || packet.unsignedChatContent, type: packet.type, sender: packet.senderUuid, - senderName: packet.networkName, - targetName: packet.networkTargetName, - verified + senderName: packet.senderName, + senderTeam: packet.senderTeam, + verified: (pubKey && !packet.unsignedChatContent) ? client.verifyMessage(pubKey, packet) : false }) - - // 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._lastSeenMessages.push({ sender: packet.senderUuid, signature: packet.signature }) - client._lastChatHistory.push({ - previousSignature: packet.previousSignature, - signature: packet.signature, - message: { - plain: packet.plainMessage, - decorated: packet.formattedMessage - }, - messageHash: packet.bodyDigest, - timestamp: packet.timestamp, - salt: packet.salt, - lastSeen: packet.previousMessages - }) - - if (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 - } }) // Chat Sending @@ -175,6 +303,38 @@ module.exports = function (client, options) { options.timestamp = options.timestamp || BigInt(Date.now()) options.salt = options.salt || 1n + if (mcData.supportFeature('useChatSessions')) { + 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 + + client.write('chat_message', { + message, + timestamp: options.timestamp, + salt: options.salt, + signature: client.profileKeys ? client.signMessage(message, options.timestamp, options.salt, undefined, acknowledgements) : undefined, + offset: client._lastSeenMessages.pending, + acknowledged: bitset + }) + client._lastSeenMessages.pending = 0 + + return + } + if (options.skipPreview || !client.serverFeatures.chatPreview) { client.write('chat_message', { message, @@ -207,10 +367,18 @@ module.exports = function (client, options) { }) // Signing methods - client.signMessage = (message, timestamp, salt = 0, preview) => { + 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('chainedChatWithHashing')) { + 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) @@ -290,11 +458,39 @@ module.exports = function (client, options) { } } +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.signature.length === 0) return // We do not acknowledge unsigned messages + if (!e) 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] @@ -308,5 +504,6 @@ class LastSeenMessages extends Array { if (!current || (current.sender === e.sender)) break } } + return true } } diff --git a/src/client/play.js b/src/client/play.js index 5981726..dca101f 100644 --- a/src/client/play.js +++ b/src/client/play.js @@ -1,5 +1,6 @@ const states = require('../states') const signedChatPlugin = require('./chat') +const uuid = require('uuid-1345') module.exports = function (client, options) { client.serverFeatures = {} @@ -18,6 +19,20 @@ module.exports = function (client, options) { client.uuid = packet.uuid client.username = packet.username + if (mcData.supportFeature('useChatSessions') && client.profileKeys) { + client._session = { + index: 0, + uuid: uuid.v4fast() + } + + client.write('session', { + 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 + }) + } + 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') diff --git a/src/client/setProtocol.js b/src/client/setProtocol.js index cb3ec80..e656744 100644 --- a/src/client/setProtocol.js +++ b/src/client/setProtocol.js @@ -25,10 +25,9 @@ module.exports = function (client, options) { nextState: 2 }) client.state = states.LOGIN - client.write('login_start', { username: client.username, - signature: client.profileKeys + 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 diff --git a/src/index.d.ts b/src/index.d.ts index bb5c5f4..21a3c3a 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -36,7 +36,7 @@ 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): Buffer + 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 on(event: 'error', listener: (error: Error) => PromiseLike): this diff --git a/src/server/chat.js b/src/server/chat.js index 4e52314..bab6582 100644 --- a/src/server/chat.js +++ b/src/server/chat.js @@ -2,6 +2,7 @@ const crypto = require('crypto') 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) { @@ -39,8 +40,9 @@ function validateLastMessages (pending, 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 = new Pending() + const pending = client.supportFeature('useChatSessions') ? new LastSeenMessages() : new Pending() if (!options.generatePreview) options.generatePreview = message => message @@ -57,6 +59,44 @@ module.exports = function (client, server, options) { } } + 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('session', (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) => { @@ -67,19 +107,30 @@ module.exports = function (client, server, options) { } lastTimestamp = packet.timestamp - // Checks here: 1) make sure client can chat, 2) chain is OK, 3) signature is OK, 4) log if expired + // 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.verifyMessage(packet)) raise('multiplayer.disconnect.unsigned_chat') + 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', validateMessageChain) + 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('chainedChatWithHashing')) { // 1.19.1+ + 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) @@ -124,6 +175,53 @@ module.exports = function (client, server, options) { } } +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 = [] diff --git a/src/server/login.js b/src/server/login.js index bfa9d40..df52a36 100644 --- a/src/server/login.js +++ b/src/server/login.js @@ -60,7 +60,7 @@ module.exports = function (client, server, options) { try { const publicKey = crypto.createPublicKey({ key: packet.signature.publicKey, format: 'der', type: 'spki' }) - const signable = mcData.supportFeature('chainedChatWithHashing') + 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) diff --git a/src/version.js b/src/version.js index 04fb381..75cf507 100644 --- a/src/version.js +++ b/src/version.js @@ -1,6 +1,6 @@ 'use strict' module.exports = { - defaultVersion: '1.19.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', '1.19'] + defaultVersion: '1.19.3', + 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', '1.19.2', '1.19.3'] } diff --git a/test/clientTest.js b/test/clientTest.js index 01d0b24..9179626 100644 --- a/test/clientTest.js +++ b/test/clientTest.js @@ -105,7 +105,7 @@ for (const supportedVersion of mc.supportedVersions) { })) client.on('error', err => done(err)) 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.') @@ -119,11 +119,62 @@ for (const supportedVersion of mc.supportedVersions) { client.chat('hello everyone; I have logged in.') }) - // 1.18 and below - client.on('chat', function (packet) { + client.on('playerChat', function (data) { chatCount += 1 assert.ok(chatCount <= 2) - const message = JSON.parse(packet.message) + + 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 message = JSON.parse(data.formattedMessage || JSON.stringify({ text: data.plainMessage })) + + if (chatCount === 1) { + assert.strictEqual(message.text, 'hello everyone; I have logged in.') + const sender = JSON.parse(data.senderName) + assert.deepEqual(sender.clickEvent, { + action: 'suggest_command', + value: '/tell Player ' + }) + assert.strictEqual(sender.text, 'Player') + } else if (chatCount === 2) { + assert.strictEqual(message.text, 'hello') + const sender = JSON.parse(data.senderName) + assert.strictEqual(sender.text, '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(data.formattedMessage) if (chatCount === 1) { assert.strictEqual(message.translate, 'chat.type.text') assert.deepEqual(message.with[0].clickEvent, { @@ -145,22 +196,6 @@ 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) { diff --git a/test/common/clientHelpers.js b/test/common/clientHelpers.js index 8847d17..0125378 100644 --- a/test/common/clientHelpers.js +++ b/test/common/clientHelpers.js @@ -1,21 +1,20 @@ module.exports = client => { - const mcData = require('minecraft-data')(client.version) - const hasSignedChat = mcData.supportFeature('signedChat') - client.nextMessage = (containing) => { return new Promise((resolve) => { function onChat (packet) { - const m = packet.message || packet.unsignedChatContent || packet.signedChatContent + 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(hasSignedChat ? 'player_chat' : 'chat', onChat) + client.on('playerChat', onChat) + client.on('systemChat', onChat) // For 1.7.10 function finish (m) { - client.off(hasSignedChat ? 'player_chat' : 'chat', onChat) + client.off('playerChat', onChat) + client.off('systemChat', onChat) resolve(m) } }) diff --git a/test/packetTest.js b/test/packetTest.js index 0568252..ae4ac5e 100644 --- a/test/packetTest.js +++ b/test/packetTest.js @@ -44,7 +44,20 @@ const values = { i8: -10, u8: 8, 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') { @@ -114,6 +127,7 @@ const values = { test7: { type: 'intArray', value: [12, 42] } } }, + previousMessages: [], compressedNbt: { type: 'compound', name: 'test', diff --git a/test/serverTest.js b/test/serverTest.js index d121562..3683572 100644 --- a/test/serverTest.js +++ b/test/serverTest.js @@ -75,6 +75,7 @@ for (const supportedVersion of mc.supportedVersions) { function sendBroadcastMessage(server, clients, message, sender) { if (mcData.supportFeature('signedChat')) { server.writeToClients(clients, 'player_chat', { + plainMessage: message, signedChatContent: '', unsignedChatContent: JSON.stringify({ text: message }), type: 0, @@ -83,7 +84,10 @@ for (const supportedVersion of mc.supportedVersions) { senderTeam: undefined, timestamp: Date.now(), salt: 0n, - signature: Buffer.alloc(0) + 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' }) @@ -318,6 +322,7 @@ for (const supportedVersion of mc.supportedVersions) { version: version.minecraftVersion, port: PORT })) + player1.on('login', async function (packet) { assert.strictEqual(packet.gameMode, 1) const player2 = applyClientHelpers(mc.createClient({ @@ -328,6 +333,7 @@ for (const supportedVersion of mc.supportedVersions) { })) const p1Join = await player1.nextMessage('player2') + assert.strictEqual(p1Join, '{"text":"player2 joined the game."}') player2.chat('hi')