diff --git a/src/client/chat.js b/src/client/chat.js index 579aac0a..e23f25fc 100644 --- a/src/client/chat.js +++ b/src/client/chat.js @@ -382,6 +382,7 @@ module.exports = function (client, options) { function getAcknowledgements () { let acc = 0 const acknowledgements = [] + const lastSeenMessages = [] for (let i = 0; i < client._lastSeenMessages.capacity; i++) { const idx = (client._lastSeenMessages.offset + i) % 20 @@ -389,6 +390,7 @@ module.exports = function (client, options) { if (message) { acc |= 1 << i acknowledgements.push(message.signature) + lastSeenMessages.push(message) message.pending = false } } @@ -400,7 +402,8 @@ module.exports = function (client, options) { return { acknowledgements, - acknowledged: bitset + acknowledged: bitset, + checksum: computeChatChecksum(lastSeenMessages) } } @@ -411,7 +414,7 @@ module.exports = function (client, options) { if (message.startsWith('/')) { const command = message.slice(1) if (mcData.supportFeature('useChatSessions')) { // 1.19.3+ - const { acknowledged, acknowledgements } = getAcknowledgements() + const { acknowledged, acknowledgements, checksum } = getAcknowledgements() const canSign = client.profileKeys && client._session const chatPacket = { command, @@ -419,7 +422,7 @@ module.exports = function (client, options) { 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+ + checksum, // 1.21.5+ acknowledged } client.write((mcData.supportFeature('seperateSignedChatCommandPacket') && canSign) ? 'chat_command_signed' : 'chat_command', chatPacket) @@ -444,14 +447,14 @@ module.exports = function (client, options) { } if (mcData.supportFeature('useChatSessions')) { - const { acknowledgements, acknowledged } = getAcknowledgements() + const { acknowledgements, acknowledged, checksum } = 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+ + checksum, // 1.21.5+ acknowledged }) client._lastSeenMessages.pending = 0 diff --git a/src/server/chat.js b/src/server/chat.js index 2258fdc6..2f9e4001 100644 --- a/src/server/chat.js +++ b/src/server/chat.js @@ -61,8 +61,15 @@ module.exports = function (client, server, options) { try { const unwrapped = pending.unwrap(packet.offset, packet.acknowledged) + if (client.supportFeature('chatGlobalIndexAndChecksum') && options.enforceChatChecksum && packet.checksum !== undefined) { + const expectedChecksum = computeChatChecksum(unwrapped.map(signature => ({ signature }))) + if (packet.checksum !== 0 && packet.checksum !== expectedChecksum) { + throw new VerificationError('Invalid chat acknowledgement checksum') + } + } + const length = Buffer.byteLength(packet.message, 'utf8') - const acknowledgements = unwrapped.length > 0 ? ['i32', unwrapped.length, 'buffer', Buffer.concat(...unwrapped)] : ['i32', 0] + 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) @@ -105,14 +112,6 @@ module.exports = function (client, server, options) { } 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 @@ -124,7 +123,7 @@ module.exports = function (client, server, options) { // 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) + const valid = pending.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')) @@ -170,7 +169,7 @@ module.exports = function (client, server, options) { // 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 + if (!options.enforceSecureProfile || !client.supportFeature('signedChat')) return // nothing signable pending.add(chatPacket.senderUuid, chatPacket.signature, chatPacket.timestamp) if (pending.length > 4096) { diff --git a/test/chatAcknowledgementTest.js b/test/chatAcknowledgementTest.js new file mode 100644 index 00000000..d85a75ba --- /dev/null +++ b/test/chatAcknowledgementTest.js @@ -0,0 +1,194 @@ +/* eslint-env mocha */ + +const assert = require('assert') +const crypto = require('crypto') +const { EventEmitter, once } = require('events') +const nbt = require('prismarine-nbt') +const mc = require('../') +const injectChatPlugin = require('../src/client/chat') +const { computeChatChecksum } = require('../src/datatypes/checksums') +const { supportedVersions } = require('../src/version') +const { getPort } = require('./common/util') + +const checksumVersions = supportedVersions.filter(version => require('minecraft-data')(version).version['>=']('1.21.5')) + +for (const version of checksumVersions) { + describe(`chat acknowledgements ${version}v`, () => { + const mcData = require('minecraft-data')(version) + + for (const [outbound, packetName] of [ + ['outbound message', 'chat_message'], + ['/outbound command', 'chat_command'] + ]) { + it(`checksums wrapped last-seen messages in acknowledgement order for ${packetName}`, () => { + const client = new EventEmitter() + client.version = version + client.uuid = '00000000-0000-0000-0000-000000000001' + + const writes = [] + client.write = (name, data) => writes.push({ name, data }) + + injectChatPlugin(client, {}) + + const signatures = [] + for (let i = 1; i <= 21; i++) { + const signature = Buffer.from([i, i + 1]) + signatures.push(signature) + client.emit('player_chat', { + signature, + senderUuid: '00000000-0000-0000-0000-000000000002', + plainMessage: `message ${i}`, + index: i, + previousMessages: [], + salt: 1n, + timestamp: 1n, + unsignedChatContent: null, + type: 0, + networkName: null, + networkTargetName: null + }) + } + + client._signedChat(outbound, { timestamp: 1n, salt: 1n }) + + const expected = computeChatChecksum( + signatures.slice(-20).map(signature => ({ signature })) + ) + assert.strictEqual(writes.length, 1) + assert.strictEqual(writes[0].name, packetName) + assert.strictEqual(writes[0].data.checksum, expected) + }) + } + + it('accepts valid and rejects invalid wrapped acknowledgements between a protocol client and server', async function () { + this.timeout(10000) + + const port = await getPort() + const options = { + 'online-mode': false, + version, + host: '127.0.0.1', + port, + enforceSecureProfile: false, + enforceChatChecksum: true, + hideErrors: true + } + const server = mc.createServer(options) + let client + + try { + const serverClientPromise = new Promise(resolve => { + server.once('playerJoin', serverClient => { + serverClient.write('login', mcData.loginPacket) + resolve(serverClient) + }) + }) + + await once(server, 'listening') + client = mc.createClient({ + username: 'checksum-test', + host: '127.0.0.1', + version, + port + }) + const loginPromise = once(client, 'login') + const serverClient = await serverClientPromise + await loginPromise + + // Offline mode gets the protocol into play state without external auth. + // Enabling validation after login lets this test provide a deterministic + // session key while exercising the real client/server packet path. + options.enforceSecureProfile = true + serverClient.settings.disabledChat = false + const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }) + const sessionUuid = '00000000-0000-0000-0000-000000000003' + client.profileKeys = { private: privateKey, public: publicKey } + client._session = { uuid: sessionUuid, index: 0 } + serverClient.profileKeys = { public: publicKey } + serverClient._session = { uuid: sessionUuid, index: 0 } + + const signatures = [] + const sendTrackedMessage = async (i) => { + const signature = Buffer.alloc(256, i) + signatures.push(signature) + const packet = { + globalIndex: i, + senderUuid: '00000000-0000-0000-0000-000000000002', + index: i, + signature, + plainMessage: `message ${i}`, + timestamp: BigInt(Date.now()), + salt: 1n, + previousMessages: [], + unsignedChatContent: nbt.comp({ text: nbt.string(`message ${i}`) }), + filterType: 0, + type: mcData.supportFeature('chatTypeIsHolder') ? { chatType: 1 } : 0, + networkName: nbt.comp({ text: nbt.string('sender') }), + networkTargetName: undefined + } + + const received = once(client, 'player_chat') + assert.strictEqual(serverClient.logSentMessageFromPeer(packet), true) + serverClient.write('player_chat', packet) + await received + } + for (let i = 1; i <= 21; i++) await sendTrackedMessage(i) + + // Exercise the standalone acknowledgement packet as well as the fields + // carried by chat packets. Normally the client sends this after 64 + // pending messages; a smaller valid offset keeps this test focused. + const receivedAcknowledgement = once(serverClient, 'message_acknowledgement') + client.write('message_acknowledgement', { count: client._lastSeenMessages.pending }) + client._lastSeenMessages.pending = 0 + await receivedAcknowledgement + + let validationFailure + const originalEnd = serverClient.end.bind(serverClient) + serverClient.end = (reason, ...args) => { + if (reason === 'multiplayer.disconnect.chat_validation_failed') validationFailure = reason + return originalEnd(reason, ...args) + } + + const receivedChat = once(serverClient, 'chat_message') + client._signedChat('wrapped acknowledgement', { + timestamp: BigInt(Date.now()), + salt: 1n + }) + const [packet] = await receivedChat + await new Promise(resolve => setImmediate(resolve)) + + const expected = computeChatChecksum( + signatures.slice(-20).map(signature => ({ signature })) + ) + assert.strictEqual(packet.checksum, expected) + assert.strictEqual(validationFailure, undefined) + + await sendTrackedMessage(22) + const originalWrite = client.write.bind(client) + client.write = (name, params) => { + if (name === 'chat_message') { + params = { + ...params, + checksum: params.checksum === 255 ? 254 : params.checksum + 1 + } + } + return originalWrite(name, params) + } + + const receivedInvalidChat = once(serverClient, 'chat_message') + client._signedChat('invalid wrapped acknowledgement', { + timestamp: BigInt(Date.now()), + salt: 2n + }) + await receivedInvalidChat + await new Promise(resolve => setImmediate(resolve)) + assert.strictEqual(validationFailure, 'multiplayer.disconnect.chat_validation_failed') + } finally { + if (client) client.end() + const closed = once(server, 'close') + server.close() + await closed + } + }) + }) +}