Skip to content
Open
5 changes: 3 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,9 @@ jobs:
distribution: 'adopt'
- name: Install dependencies
run: npm install
- run: cd node_modules && cd minecraft-data && mv minecraft-data minecraft-data-old && git clone -b pc_26_3 https://github.com/PrismarineJS/minecraft-data --depth 1 && node bin/generate_data.js
- run: curl -o node_modules/protodef/src/serializer.js https://raw.githubusercontent.com/extremeheat/node-protodef/refs/heads/dlog/src/serializer.js && curl -o node_modules/protodef/src/compiler.js https://raw.githubusercontent.com/extremeheat/node-protodef/refs/heads/dlog/src/compiler.js
# Until the 26.3 data is published: the pc_26_3 data with the 26.3 fixes (PrismarineJS/minecraft-data#1301 + the
# stacked fix PR). The published protodef 1.19 handles the 26.3 encodings, so the debug build override is gone.
- run: cd node_modules && cd minecraft-data && mv minecraft-data minecraft-data-old && git clone -b port-26.3 https://github.com/suisarbre/minecraft-data --depth 1 && node bin/generate_data.js


- name: Run tests
Expand Down
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
9 changes: 6 additions & 3 deletions src/datatypes/compiler-minecraft.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ if (n !== 0) {
}
`.trim())
}],
lpVec3: ['native', minecraft.lpVec3[0]]
lpVec3: ['native', minecraft.lpVec3[0]],
entityDelta: ['native', minecraft.entityDelta[0]]
},
Write: {
varlong: ['native', minecraft.varlong[1]],
Expand Down Expand Up @@ -137,7 +138,8 @@ if (${baseName} != null) {
return offset
`.trim())
}],
lpVec3: ['native', minecraft.lpVec3[1]]
lpVec3: ['native', minecraft.lpVec3[1]],
entityDelta: ['native', minecraft.entityDelta[1]]
},
SizeOf: {
varlong: ['native', minecraft.varlong[2]],
Expand Down Expand Up @@ -197,6 +199,7 @@ if (${baseName} != null) {
return size
`.trim())
}],
lpVec3: ['native', minecraft.lpVec3[2]]
lpVec3: ['native', minecraft.lpVec3[2]],
entityDelta: ['native', minecraft.entityDelta[2]]
}
}
97 changes: 96 additions & 1 deletion src/datatypes/minecraft.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,105 @@ module.exports = {
restBuffer: [readRestBuffer, writeRestBuffer, sizeOfRestBuffer],
entityMetadataLoop: [readEntityMetadata, writeEntityMetadata, sizeOfEntityMetadata],
topBitSetTerminatedArray: [readTopBitSetTerminatedArray, writeTopBitSetTerminatedArray, sizeOfTopBitSetTerminatedArray],
lpVec3: [readLpVec3, writeLpVec3, sizeOfLpVec3]
lpVec3: [readLpVec3, writeLpVec3, sizeOfLpVec3],
entityDelta: [readEntityDelta, writeEntityDelta, sizeOfEntityDelta]
}
const PartialReadError = require('protodef').utils.PartialReadError

// 26.3+ ClientboundMoveEntityPacket ("rel_entity_move" / "entity_move_look") delta encoding.
//
// Mojang replaced the old flat 3x-i16 delta with VecDelta, a packed `properties` varint
// (bit 0 = onGround, remaining bits = stepCount) followed by either:
// - stepCount <= 0: the old flat format, 3x i16 (dX, dY, dZ) -- the common case when the
// entity moved every tick, which is why this bug only shows up with several entities/ticks
// skipped at once.
// - stepCount > 0: `stepCount` DeltaStep entries, each read in wire order as
// {ticks: varint, dX: i16, dY: i16, dZ: i16} -- these are chained deltas for smoother
// client-side interpolation (each step's delta is relative to the position produced by
// applying the previous step, not to the original base).
//
// Confirmed by decompiling VecDelta.read()/write() and ClientboundMoveEntityPacket's
// packProperties/unpackProperties/unpackStepCount in the real 26.3 server jar.
//
// Parsed shape (uniform regardless of wire variant so downstream code never has to branch):
// { onGround: boolean, steps: [{ dX, dY, dZ, ticks }, ...] }
// For the flat/Linear wire format this is always a single-element array with ticks: 0.
function readEntityDelta (buffer, offset) {
let cursor = offset
const props = readVarInt(buffer, cursor)
cursor += props.size
const onGround = (props.value & 1) !== 0
const stepCount = props.value >>> 1

const steps = []
if (stepCount <= 0) {
if (cursor + 6 > buffer.length) throw new PartialReadError('Unexpected buffer end while reading entityDelta (linear)')
steps.push({
dX: buffer.readInt16BE(cursor),
dY: buffer.readInt16BE(cursor + 2),
dZ: buffer.readInt16BE(cursor + 4),
ticks: 0
})
cursor += 6
} else {
for (let i = 0; i < stepCount; i++) {
const ticksResult = readVarInt(buffer, cursor)
cursor += ticksResult.size
if (cursor + 6 > buffer.length) throw new PartialReadError('Unexpected buffer end while reading entityDelta (stepped)')
steps.push({
dX: buffer.readInt16BE(cursor),
dY: buffer.readInt16BE(cursor + 2),
dZ: buffer.readInt16BE(cursor + 4),
ticks: ticksResult.value
})
cursor += 6
}
}

return { value: { onGround, steps }, size: cursor - offset }
}

function writeEntityDelta (value, buffer, offset) {
const { onGround, steps } = value
const useLinear = steps.length === 1 && steps[0].ticks === 0
const stepCount = useLinear ? 0 : steps.length
const properties = (onGround ? 1 : 0) | (stepCount << 1)

offset = writeVarInt(properties, buffer, offset)
if (useLinear) {
buffer.writeInt16BE(steps[0].dX, offset)
buffer.writeInt16BE(steps[0].dY, offset + 2)
buffer.writeInt16BE(steps[0].dZ, offset + 4)
offset += 6
} else {
for (const step of steps) {
offset = writeVarInt(step.ticks, buffer, offset)
buffer.writeInt16BE(step.dX, offset)
buffer.writeInt16BE(step.dY, offset + 2)
buffer.writeInt16BE(step.dZ, offset + 4)
offset += 6
}
}
return offset
}

function sizeOfEntityDelta (value) {
const { onGround, steps } = value
const useLinear = steps.length === 1 && steps[0].ticks === 0
const stepCount = useLinear ? 0 : steps.length
const properties = (onGround ? 1 : 0) | (stepCount << 1)

let size = sizeOfVarInt(properties)
if (useLinear) {
size += 6
} else {
for (const step of steps) {
size += sizeOfVarInt(step.ticks) + 6
}
}
return size
}

function readVarLong (buffer, offset) {
return readVarInt(buffer, offset)
}
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
3 changes: 2 additions & 1 deletion src/server/login.js
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,8 @@ module.exports = function (client, server, options) {
client.write('success', {
uuid: client.uuid,
username: client.username,
properties: []
properties: [],
sessionId: crypto.randomUUID() // added to packet_success in 26.2 (protocol 776); ignored by protodef on older versions' schemas
})
if (client.supportFeature('hasConfigurationState')) {
client.once('login_acknowledged', onClientLoginAck)
Expand Down
13 changes: 12 additions & 1 deletion src/transforms/compression.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ const [readVarInt, writeVarInt, sizeOfVarInt] = require('protodef').types.varint
const zlib = require('zlib')
const Transform = require('readable-stream').Transform

// The protocol caps the uncompressed size of a packet at 2^23 bytes (8 MiB). A compressed
// packet that declares — or inflates to — more than this is rejected, so a malicious peer
// cannot force an unbounded synchronous allocation (a "decompression bomb"). See #664.
const MAX_UNCOMPRESSED_LENGTH = 8388608 // 2 ** 23

module.exports.createCompressor = function (threshold) {
return new Compressor(threshold)
}
Expand Down Expand Up @@ -53,9 +58,15 @@ class Decompressor extends Transform {
if (value === 0) {
this.push(chunk.slice(size))
return cb()
} else if (value > MAX_UNCOMPRESSED_LENGTH) {
// Declared uncompressed length exceeds the protocol maximum; refuse to inflate.
if (!this.hideErrors) {
console.error('uncompressed length ' + value + ' exceeds maximum of ' + MAX_UNCOMPRESSED_LENGTH)
}
return cb()
} else {
try {
const newBuf = zlib.unzipSync(chunk.slice(size), { finishFlush: 2 })
const newBuf = zlib.unzipSync(chunk.slice(size), { maxOutputLength: MAX_UNCOMPRESSED_LENGTH, finishFlush: 2 })
if (newBuf.length !== value && !this.hideErrors) {
console.error('uncompressed length should be ' + value + ' but is ' + newBuf.length)
}
Expand Down
Loading