Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions src/client/chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -382,13 +382,15 @@ 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
const message = client._lastSeenMessages[idx]
if (message) {
acc |= 1 << i
acknowledgements.push(message.signature)
lastSeenMessages.push(message)
message.pending = false
}
}
Expand All @@ -400,7 +402,8 @@ module.exports = function (client, options) {

return {
acknowledgements,
acknowledged: bitset
acknowledged: bitset,
checksum: computeChatChecksum(lastSeenMessages)
}
}

Expand All @@ -411,15 +414,15 @@ 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,
timestamp: options.timestamp,
salt: options.salt,
argumentSignatures: canSign ? signaturesForCommand(command, options.timestamp, options.salt, options.preview, acknowledgements) : [],
messageCount: client._lastSeenMessages.pending,
checksum: computeChatChecksum(client._lastSeenMessages), // 1.21.5+
checksum, // 1.21.5+
acknowledged
}
client.write((mcData.supportFeature('seperateSignedChatCommandPacket') && canSign) ? 'chat_command_signed' : 'chat_command', chatPacket)
Expand All @@ -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
Expand Down
21 changes: 10 additions & 11 deletions src/server/chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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'))
Expand Down Expand Up @@ -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) {
Expand Down
194 changes: 194 additions & 0 deletions test/chatAcknowledgementTest.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
/* eslint-env mocha */
Comment thread
Pix3lPirat3 marked this conversation as resolved.

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