Skip to content
Draft
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
3 changes: 3 additions & 0 deletions docs/HISTORY.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# History

## 1.56.0
* Add support for Minecraft 1.21.5

## 1.55.0
* [Fix `client.end()` (#1376)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/3bd4dc1b2002cd7badfa5b9cf8dda35cd6cc9ac1) (thanks @h5mcbox)
* [Fix #1369 online-mode error 1.20.5-1.21.4 (#1375)](https://github.com/PrismarineJS/node-minecraft-protocol/commit/5ec3dd4b367fcc039fbcb3edd214fe3cf8178a6d) (thanks @h5mcbox)
Expand Down
2 changes: 1 addition & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Parse and serialize minecraft packets, plus authentication and encryption.

* Supports Minecraft PC version 1.7.10, 1.8.8, 1.9 (15w40b, 1.9, 1.9.1-pre2, 1.9.2, 1.9.4),
1.10 (16w20a, 1.10-pre1, 1.10, 1.10.1, 1.10.2), 1.11 (16w35a, 1.11, 1.11.2), 1.12 (17w15a, 17w18b, 1.12-pre4, 1.12, 1.12.1, 1.12.2), and 1.13 (17w50a, 1.13, 1.13.1, 1.13.2-pre1, 1.13.2-pre2, 1.13.2), 1.14 (1.14, 1.14.1, 1.14.3, 1.14.4)
, 1.15 (1.15, 1.15.1, 1.15.2) and 1.16 (20w13b, 20w14a, 1.16-rc1, 1.16, 1.16.1, 1.16.2, 1.16.3, 1.16.4, 1.16.5), 1.17 (21w07a, 1.17, 1.17.1), 1.18 (1.18, 1.18.1 and 1.18.2), 1.19 (1.19, 1.19.1, 1.19.2, 1.19.3, 1.19.4), 1.20 (1.20, 1.20.1, 1.20.2, 1.20.3, 1.20.4, 1.20.5, 1.20.6), 1.21 (1.21, 1.21.1, 1.21.3, 1.21.4)
, 1.15 (1.15, 1.15.1, 1.15.2) and 1.16 (20w13b, 20w14a, 1.16-rc1, 1.16, 1.16.1, 1.16.2, 1.16.3, 1.16.4, 1.16.5), 1.17 (21w07a, 1.17, 1.17.1), 1.18 (1.18, 1.18.1 and 1.18.2), 1.19 (1.19, 1.19.1, 1.19.2, 1.19.3, 1.19.4), 1.20 (1.20, 1.20.1, 1.20.2, 1.20.3, 1.20.4, 1.20.5, 1.20.6), 1.21 (1.21, 1.21.1, 1.21.3, 1.21.4, 1.21.5)
* Parses all packets and emits events with packet fields as JavaScript
objects.
* Send a packet by supplying fields as a JavaScript object.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
"endian-toggle": "^0.0.0",
"lodash.get": "^4.1.2",
"lodash.merge": "^4.3.0",
"minecraft-data": "^3.78.0",
"minecraft-data": "^3.85.0",
"minecraft-folder-path": "^1.2.0",
"node-fetch": "^2.6.1",
"node-rsa": "^0.4.2",
Expand Down
90 changes: 82 additions & 8 deletions src/client/chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,19 @@ module.exports = function (client, options) {
client._lastChatSignature = null
client._lastRejectedMessage = null

// Chat packet index tracking (for 1.21.5+)
client._expectedChatIndex = 0

client.on('login', () => {
// Reset expected chat index on login
client._expectedChatIndex = 0
})

// Reset expected chat index when entering configuration state
client.on('finish_configuration', () => {
client._expectedChatIndex = 0
})

// 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()
Expand All @@ -45,6 +58,23 @@ module.exports = function (client, options) {
}
}()

// Calculate checksum for the lastSeen message signatures (1.21.5+)
const calculateChecksum = (lastSeen) => {
if (!mcData.supportFeature('chatChecksum') || !lastSeen || lastSeen.length === 0) {
return 0 // Default value for backward compatibility
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you elaborate here what you mean by "backward compatibility"?

}

// Simple hash calculation from the last seen signatures
let checksum = 0
for (const msg of lastSeen) {
if (msg.messageSignature) {
// XOR the first byte of each signature for a simple checksum
checksum ^= msg.messageSignature[0] || 0
}
}
return checksum
}

function updateAndValidateSession (uuid, message, currentSignature, index, previousMessages, salt, timestamp) {
const player = client._players[uuid]

Expand Down Expand Up @@ -195,6 +225,16 @@ module.exports = function (client, options) {
})

client.on('player_chat', (packet) => {
// Validate chat packet index for 1.21.5+
if (mcData.supportFeature('chatIndex') && 'index' in packet) {
if (packet.index !== client._expectedChatIndex) {
client.emit('error', new Error(`Chat index mismatch. Expected: ${client._expectedChatIndex}, got: ${packet.index}`))
client.end('Chat message received out of order')
return
Comment on lines +231 to +233
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as other comment

}
client._expectedChatIndex++
}

if (mcData.supportFeature('useChatSessions')) {
const tsDelta = BigInt(Date.now()) - packet.timestamp
const expired = !packet.timestamp || tsDelta > messageExpireTime || tsDelta < 0
Expand Down Expand Up @@ -372,17 +412,27 @@ module.exports = function (client, options) {
if (mcData.supportFeature('useChatSessions')) { // 1.19.3+
const { acknowledged, acknowledgements } = getAcknowledgements()
const canSign = client.profileKeys && client._session
client.write((mcData.supportFeature('seperateSignedChatCommandPacket') && canSign) ? 'chat_command_signed' : 'chat_command', {

// Prepare the packet parameters
const params = {
command,
timestamp: options.timestamp,
salt: options.salt,
argumentSignatures: canSign ? signaturesForCommand(command, options.timestamp, options.salt, options.preview, acknowledgements) : [],
messageCount: client._lastSeenMessages.pending,
acknowledged
})
}

// Add checksum for 1.21.5+
if (mcData.supportFeature('chatChecksum')) {
params.checksum = options.checksum !== undefined ? options.checksum : calculateChecksum(client._lastSeenMessages)
Comment on lines +426 to +428
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: if both the if branches rely on same code you can avoid the duplicate code here by putting it up top

}

client.write((mcData.supportFeature('seperateSignedChatCommandPacket') && canSign) ? 'chat_command_signed' : 'chat_command', params)
client._lastSeenMessages.pending = 0
} else {
client.write('chat_command', {
// Prepare the packet parameters
const params = {
command,
timestamp: options.timestamp,
salt: options.salt,
Expand All @@ -393,29 +443,46 @@ module.exports = function (client, options) {
messageSignature: e.signature
})),
lastRejectedMessage: client._lastRejectedMessage
})
}

// Add checksum for 1.21.5+
if (mcData.supportFeature('chatChecksum')) {
params.checksum = options.checksum !== undefined ? options.checksum : calculateChecksum(client._lastSeenMessages)
}

client.write('chat_command', params)
}

return
}

if (mcData.supportFeature('useChatSessions')) {
const { acknowledgements, acknowledged } = getAcknowledgements()
client.write('chat_message', {

// Prepare the packet parameters
const params = {
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,
acknowledged
})
}

// Add checksum for 1.21.5+
if (mcData.supportFeature('chatChecksum')) {
params.checksum = options.checksum !== undefined ? options.checksum : calculateChecksum(client._lastSeenMessages)
}

client.write('chat_message', params)
client._lastSeenMessages.pending = 0

return
}

if (options.skipPreview || !client.serverFeatures.chatPreview) {
client.write('chat_message', {
// Prepare the packet parameters
const params = {
message,
timestamp: options.timestamp,
salt: options.salt,
Expand All @@ -426,7 +493,14 @@ module.exports = function (client, options) {
messageSignature: e.signature
})),
lastRejectedMessage: client._lastRejectedMessage
})
}

// Add checksum for 1.21.5+
if (mcData.supportFeature('chatChecksum')) {
params.checksum = options.checksum !== undefined ? options.checksum : calculateChecksum(client._lastSeenMessages)
}

client.write('chat_message', params)
client._lastSeenMessages.pending = 0
} else if (client.serverFeatures.chatPreview) {
client.write('chat_preview', {
Expand Down
11 changes: 10 additions & 1 deletion src/client/play.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,13 @@ module.exports = function (client, options) {

function enterConfigState (finishCb) {
if (client.state === states.CONFIGURATION) return
// If we are returning to the configuration state from the play state, we ahve to acknowledge it.
// If we are returning to the configuration state from the play state, we have to acknowledge it.
if (client.state === states.PLAY) {
client.write('configuration_acknowledged', {})
// Reset chat index counter when returning to configuration state
if (client._expectedChatIndex !== undefined) {
client._expectedChatIndex = 0
}
}
client.state = states.CONFIGURATION
client.on('select_known_packs', () => {
Expand All @@ -66,6 +70,11 @@ module.exports = function (client, options) {
}

function onReady () {
// Reset chat index on player join
if (mcData.supportFeature('chatIndex')) {
client._expectedChatIndex = 0
}

client.emit('playerJoin')
if (mcData.supportFeature('signedChat')) {
if (options.disableChatSigning && client.serverFeatures.enforcesSecureChat) {
Expand Down
59 changes: 59 additions & 0 deletions src/server/chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,19 @@ module.exports = function (client, server, options) {

if (!options.generatePreview) options.generatePreview = message => message

// Chat index tracking for 1.21.5+
client._chatMessageIndex = 0

// Reset chat index when client enters play state
client.on('login', () => {
client._chatMessageIndex = 0
})

// Reset chat index when entering configuration state
client.on('finish_configuration', () => {
client._chatMessageIndex = 0
})

function validateMessageChain (packet) {
try {
validateLastMessages(pending, packet.previousMessages, packet.lastRejectedMessage)
Expand Down Expand Up @@ -104,6 +117,16 @@ module.exports = function (client, server, options) {
}
lastTimestamp = packet.timestamp

// Verify checksum if supported
if (client.supportFeature('chatChecksum') && 'checksum' in packet) {
const calculatedChecksum = calculateChecksum(packet.previousMessages)
if (packet.checksum !== 0 && packet.checksum !== calculatedChecksum) {
debug('Chat checksum mismatch', packet.checksum, calculatedChecksum)
raise('multiplayer.disconnect.chat_validation_failed')
return
Comment on lines +122 to +126
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure we should be enforcing all these checks at the library level as opposed to the user level. We did add some abstractions to chat for the purpose of not having to reimplement complex logic on the user's side. Perhaps it would make sense to have these kind of kick checks behind a flag.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@extremeheat is there an existing flag that these kinds of kick checks could be behind or would a new flag have to be created?

}
}

// 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 @@ -112,6 +135,17 @@ module.exports = function (client, server, options) {
if ((BigInt(Date.now()) - packet.timestamp) > messageExpireTime) debug(client.socket.address(), 'sent expired message TS', packet.timestamp)
})

client.on('chat_command', (packet) => {
// Verify checksum if supported
if (client.supportFeature('chatChecksum') && 'checksum' in packet) {
const calculatedChecksum = calculateChecksum(packet.previousMessages)
if (packet.checksum !== 0 && packet.checksum !== calculatedChecksum) {
debug('Chat command checksum mismatch', packet.checksum, calculatedChecksum)
raise('multiplayer.disconnect.chat_validation_failed')
}
}
})

// 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')) {
Expand Down Expand Up @@ -170,6 +204,31 @@ module.exports = function (client, server, options) {
}
return true
}

// Helper function to calculate checksum
function calculateChecksum (messages) {
if (!messages || messages.length === 0) return 0

let checksum = 0
for (const msg of messages) {
if (msg.signature) {
// XOR the first byte of each signature for a simple checksum
checksum ^= msg.signature[0] || 0
}
}
return checksum
}
Comment on lines +209 to +220
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The checksum algorithm doesn't seem to match Minecraft, did you write this intentionally? The server does verify this, so this has to be correct if chat signing is enabled (the default) or the server will kick for CHAT_VALIDATION_FAILED. I attached some logs from Gemini explaining the changes below


// Override and extend logSentMessageFromPeer to increment index
const originalLogSent = client.logSentMessageFromPeer
client.logSentMessageFromPeer = function (chatPacket) {
// Include chat index in outgoing player_chat packets
if (client.supportFeature('chatIndex')) {
chatPacket.index = client._chatMessageIndex++
}

return originalLogSent ? originalLogSent.call(this, chatPacket) : true
}
Comment on lines +222 to +231
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not good

Copy link

@Arc8ne Arc8ne Jun 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@extremeheat just to clarify, why is that change not good?

}

class LastSeenMessages extends Array {
Expand Down
2 changes: 1 addition & 1 deletion src/version.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@

module.exports = {
defaultVersion: '1.21.1',
supportedVersions: ['1.7', '1.8.8', '1.9.4', '1.10.2', '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', '1.19.4', '1.20', '1.20.1', '1.20.2', '1.20.4', '1.20.6', '1.21.1', '1.21.3', '1.21.4']
supportedVersions: ['1.7', '1.8.8', '1.9.4', '1.10.2', '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', '1.19.4', '1.20', '1.20.1', '1.20.2', '1.20.4', '1.20.6', '1.21.1', '1.21.3', '1.21.4', '1.21.5']
}
Loading