minecraft-protocol
Version:
Parse and serialize minecraft packets, plus authentication and encryption.
274 lines (231 loc) • 11.3 kB
JavaScript
const crypto = require('crypto')
const { computeChatChecksum } = require('../datatypes/checksums')
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) {
if (lastRejected) {
const rejectedTime = pending.get(lastRejected.sender, lastRejected.signature)
if (rejectedTime) pending.acknowledge(lastRejected.sender, lastRejected.signature)
else throw new VerificationError(`Client rejected a message we never sent from '${lastRejected.sender}'`)
}
let lastTimestamp
const seenSenders = new Set()
for (const { messageSender, messageSignature } of lastSeen) {
if (pending.previouslyAcknowledged(messageSender, messageSignature)) continue
const ts = pending.get(messageSender)(messageSignature)
if (!ts) {
throw new VerificationError(`Client saw a message that we never sent from '${messageSender}'`)
} else if (lastTimestamp && (ts < lastTimestamp)) {
throw new VerificationError(`Received messages out of order: Last acknowledged timestamp was at ${lastTimestamp}, now reading older message at ${ts}`)
} else if (seenSenders.has(messageSender)) {
// in the lastSeen array, last 5 messages from different players are stored, not just last 5 messages
throw new VerificationError(`Two last seen entries from same player not allowed: ${messageSender}`)
} else {
lastTimestamp = ts
seenSenders.add(messageSender)
pending.acknowledgePrior(messageSender, messageSignature)
}
}
pending.setPreviouslyAcknowledged(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 = client.supportFeature('useChatSessions') ? new LastSeenMessages() : new Pending()
if (!options.generatePreview) options.generatePreview = message => message
function validateMessageChain (packet) {
try {
validateLastMessages(pending, packet.previousMessages, packet.lastRejectedMessage)
} 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)
}
}
}
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('chat_session_update', (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) => {
if (!options.enforceSecureProfile) return // nothing signable
if ((lastTimestamp && packet.timestamp < lastTimestamp) || (packet.timestamp > Date.now())) {
return raise('multiplayer.disconnect.out_of_order_chat')
}
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
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', (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('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)
verifier.update(concat('UUID', client.uuid))
// Hash of chat body now opposed to signing plaintext. This lets server give us hashes for chat
// chain without needing to reveal message contents
if (packet.bodyDigest) {
// Header
verifier.update(packet.bodyDigest)
} else {
// Player Chat
const hash = crypto.createHash('sha256')
hash.update(concat('i64', packet.salt, 'i64', packet.timestamp / 1000n, 'pstring', packet.message, 'i8', 70))
if (packet.signedPreview) hash.update(options.generatePreview(packet.message))
for (const { messageSender, messageSignature } of packet.previousMessages) {
hash.update(concat('i8', 70, 'UUID', messageSender))
hash.update(messageSignature)
}
// Feed hash back into signing payload
verifier.update(hash.digest())
}
client._lastChatSignature = packet.signature
return verifier.verify(client.profileKeys.public, packet.signature)
} else { // 1.19
const signable = concat('i64', packet.salt, 'UUID', client.uuid, 'i64', packet.timestamp, 'pstring', packet.message)
return crypto.verify('sha256WithRSAEncryption', signable, client.profileKeys.public, packet.signature)
}
}
// 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
pending.add(chatPacket.senderUuid, chatPacket.signature, chatPacket.timestamp)
if (pending.length > 4096) {
raise('multiplayer.disconnect.too_many_pending_chats')
return false
}
return true
}
}
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 = []
get (sender, signature) {
return this.m[sender]?.[signature]
}
add (sender, signature, ts) {
this.m[sender] = this.m[sender] || {}
this.m[sender][signature] = ts
this.push([sender, signature])
}
acknowledge (sender, username) {
delete this.m[sender][username]
this.splice(this.findIndex(([a, b]) => a === sender && b === username), 1)
}
acknowledgePrior (sender, signature) {
for (let i = 0; i < this.length; i++) {
const [a, b] = this[i]
delete this.m[a]
if (a === sender && b === signature) {
this.splice(0, i)
break
}
}
}
// Once we've acknowledged that the client has saw the messages we sent,
// we delete it from our map & pending list. However, the client may keep it in
// their 5-length lastSeen list anyway. Once we verify/ack the client's lastSeen array,
// we need to store it in memory to allow those entries to be approved again without
// erroring about a message we never sent in the next serverbound message packet we get.
setPreviouslyAcknowledged (lastSeen, lastRejected = {}) {
this.lastSeen = lastSeen.map(e => Object.values(e)).push(Object.values(lastRejected))
}
previouslyAcknowledged (sender, signature) {
return this.lastSeen.some(([a, b]) => a === sender && b === signature)
}
}