mirror of
https://github.com/PrismarineJS/node-minecraft-protocol.git
synced 2024-11-14 19:04:59 -05:00
Update to 1.19.3 (#1069)
* Add 1.19.3 player_info parsing * player_remove packet parsing * 1.19.3 chat parsing * Outgoing chat for 1.19.3 * Fix lint * Server chat validation * add 1.19.2 and 1.19.3 in version.js * Add 1.19.2 and 1.19.3 in ci.yml * Deprecated client.verifyMessage for server clients * Update docs * Deprecate client.verifyMessage for server clients * Fix tests * Fix lint * Fix packetTest * Fix test * Remove unneeded log statement * Update types/docs * Remove unnecessary feature check * Remove _session from docs Co-authored-by: Romain Beaumont <romain.rom1@gmail.com>
This commit is contained in:
parent
92a5219915
commit
cf1f67117d
13 changed files with 461 additions and 98 deletions
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
@ -13,7 +13,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
mcVersion: ['1.7', '1.8', '1.9', '1.10', '1.11.2', '1.12.2', '1.13.2', '1.14.4', '1.15.2', '1.16.5', '1.17', '1.17.1', '1.18.2', '1.19']
|
||||
mcVersion: ['1.7', '1.8', '1.9', '1.10', '1.11.2', '1.12.2', '1.13.2', '1.14.4', '1.15.2', '1.16.5', '1.17', '1.17.1', '1.18.2', '1.19', '1.19.2', '1.19.3']
|
||||
fail-fast: false
|
||||
|
||||
steps:
|
||||
|
|
|
@ -44,7 +44,7 @@ Write a packet to all `clients` but encode it only once.
|
|||
|
||||
### client.verifyMessage(packet) : boolean
|
||||
|
||||
Verifies if player's chat message packet was signed with their Mojang provided key
|
||||
(1.19-1.19.2) Verifies if player's chat message packet was signed with their Mojang provided key. Handled internally (and thus deprecated) in 1.19.3 and above
|
||||
|
||||
### client.logSentMessageFromPeer(packet)
|
||||
(1.19.1+) You must call this function when the server receives a message from a player and that message gets
|
||||
|
@ -306,7 +306,7 @@ Unregister a channel `name` and send the unregister packet if `custom` is true.
|
|||
### client.chat(message)
|
||||
Send a chat message to the server, with signing on 1.19+.
|
||||
|
||||
### client.signMessage(message: string, timestamp: BigInt, salt?: number) : Buffer
|
||||
### client.signMessage(message: string, timestamp: BigInt, salt?: number, preview?: string, acknowledgements?: Buffer[]) : Buffer
|
||||
|
||||
(1.19) Generate a signature for a chat message to be sent to server
|
||||
|
||||
|
|
|
@ -22,8 +22,13 @@ module.exports = function (client, options) {
|
|||
client._lastChatSignature = null
|
||||
client._lastRejectedMessage = null
|
||||
|
||||
// This stores the last 5 messages that the player has seen, from unique players
|
||||
client._lastSeenMessages = new LastSeenMessages()
|
||||
// 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()
|
||||
|
||||
// This stores the last 128 inbound (signed) messages for 1.19.3 chat validation
|
||||
client._signatureCache = new SignatureCache()
|
||||
|
||||
// This stores last 1024 inbound messages for report lookup
|
||||
client._lastChatHistory = new class extends Array {
|
||||
capacity = 1024
|
||||
|
@ -35,6 +40,31 @@ module.exports = function (client, options) {
|
|||
}
|
||||
}()
|
||||
|
||||
function updateAndValidateSession (uuid, message, currentSignature, index, previousMessages, salt, timestamp) {
|
||||
const player = client._players[uuid]
|
||||
|
||||
if (player && player.hasChainIntegrity) {
|
||||
if (!player.lastSignature || player.lastSignature.equals(currentSignature) || index > player.sessionIndex) {
|
||||
player.lastSignature = currentSignature
|
||||
} else {
|
||||
player.hasChainIntegrity = false
|
||||
}
|
||||
|
||||
if (player.hasChainIntegrity) {
|
||||
const length = Buffer.byteLength(message, 'utf8')
|
||||
const acknowledgements = previousMessages.length > 0 ? ['i32', previousMessages.length, 'buffer', Buffer.concat(...previousMessages.map(msg => msg.signature || client._signatureCache[msg.id]))] : ['i32', 0]
|
||||
|
||||
const signable = concat('i32', 1, 'UUID', uuid, 'UUID', player.sessionUuid, 'i32', index, 'i64', salt, 'i64', timestamp / 1000n, 'i32', length, 'pstring', message, ...acknowledgements)
|
||||
|
||||
player.hasChainIntegrity = crypto.verify('RSA-SHA256', signable, player.publicKey, currentSignature)
|
||||
}
|
||||
|
||||
return player.hasChainIntegrity
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function updateAndValidateChat (uuid, previousSignature, currentSignature, payload) {
|
||||
// Get the player information
|
||||
const player = client._players[uuid]
|
||||
|
@ -63,7 +93,30 @@ module.exports = function (client, options) {
|
|||
return false
|
||||
}
|
||||
|
||||
client.on('player_remove', (packet) => {
|
||||
for (const player of packet.players) {
|
||||
delete client._players[player.UUID]
|
||||
}
|
||||
})
|
||||
|
||||
client.on('player_info', (packet) => {
|
||||
if (mcData.supportFeature('playerInfoActionIsBitfield')) { // 1.19.3+
|
||||
if (packet.action & 2) { // chat session
|
||||
for (const player of packet.data) {
|
||||
if (!player.chatSession) continue
|
||||
client._players[player.UUID] = {
|
||||
publicKey: crypto.createPublicKey({ key: player.chatSession.publicKey.keyBytes, format: 'der', type: 'spki' }),
|
||||
publicKeyDER: player.chatSession.publicKey.keyBytes,
|
||||
sessionUuid: player.chatSession.uuid
|
||||
}
|
||||
client._players[player.UUID].sessionIndex = true
|
||||
client._players[player.UUID].hasChainIntegrity = true
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (packet.action === 0) { // add player
|
||||
for (const player of packet.data) {
|
||||
if (player.crypto) {
|
||||
|
@ -83,88 +136,163 @@ module.exports = function (client, options) {
|
|||
}
|
||||
})
|
||||
|
||||
client.on('profileless_chat', (packet) => {
|
||||
// Profileless chat is parsed as an unsigned player chat message but logged as a system message
|
||||
|
||||
client.emit('playerChat', {
|
||||
formattedMessage: packet.message,
|
||||
type: packet.type,
|
||||
senderName: packet.name,
|
||||
targetName: packet.target,
|
||||
verified: false
|
||||
})
|
||||
|
||||
client._lastChatHistory.push({
|
||||
type: 2, // System message
|
||||
message: {
|
||||
decorated: packet.content // This should actually decorate the message with the sender and target name using the chat type
|
||||
},
|
||||
timestamp: Date.now()
|
||||
})
|
||||
})
|
||||
|
||||
client.on('system_chat', (packet) => {
|
||||
client.emit('systemChat', {
|
||||
positionid: packet.isActionBar ? 2 : 1,
|
||||
formattedMessage: packet.content
|
||||
})
|
||||
|
||||
client._lastChatHistory.push({
|
||||
type: 2, // System message
|
||||
message: {
|
||||
decorated: packet.content
|
||||
},
|
||||
timestamp: Date.now()
|
||||
})
|
||||
})
|
||||
|
||||
client.on('message_header', (packet) => {
|
||||
updateAndValidateChat(packet.senderUuid, packet.previousSignature, packet.signature, packet.messageHash)
|
||||
|
||||
client._lastChatHistory.push({
|
||||
type: 1, // Message header
|
||||
previousSignature: packet.previousSignature,
|
||||
signature: packet.signature,
|
||||
messageHash: packet.messageHash
|
||||
})
|
||||
})
|
||||
|
||||
client.on('hide_message', (packet) => {
|
||||
if (mcData.supportFeature('useChatSessions')) {
|
||||
const signature = packet.signature || client._signatureCache[packet.id]
|
||||
if (signature) client._lastSeenMessages = client._lastSeenMessages.map(ack => (ack.signature === signature && ack.pending) ? null : ack)
|
||||
}
|
||||
})
|
||||
|
||||
client.on('player_chat', (packet) => {
|
||||
if (!mcData.supportFeature('chainedChatWithHashing')) { // 1.19.0
|
||||
const pubKey = client._players[packet.senderUuid]?.publicKey
|
||||
if (mcData.supportFeature('useChatSessions')) {
|
||||
const tsDelta = BigInt(Date.now()) - packet.timestamp
|
||||
const expired = !packet.timestamp || tsDelta > messageExpireTime || tsDelta < 0
|
||||
const verified = !packet.unsignedChatContent && updateAndValidateSession(packet.senderUuid, packet.plainMessage, packet.signature, packet.index, packet.previousMessages, packet.salt, packet.timestamp) && !expired
|
||||
client.emit('playerChat', {
|
||||
formattedMessage: packet.signedChatContent || packet.unsignedChatContent,
|
||||
plainMessage: packet.plainMessage,
|
||||
unsignedContent: packet.unsignedContent,
|
||||
type: packet.type,
|
||||
sender: packet.senderUuid,
|
||||
senderName: packet.senderName,
|
||||
senderTeam: packet.senderTeam,
|
||||
verified: (pubKey && !packet.unsignedChatContent) ? client.verifyMessage(pubKey, packet) : false
|
||||
senderName: packet.networkName,
|
||||
targetName: packet.networkTargetName,
|
||||
verified
|
||||
})
|
||||
|
||||
client._lastChatHistory.push({
|
||||
type: 0, // Player message
|
||||
signature: packet.signature,
|
||||
message: {
|
||||
plain: packet.plainMessage
|
||||
},
|
||||
session: {
|
||||
index: packet.index,
|
||||
uuid: client._players[packet.senderUuid]?.sessionUuid
|
||||
},
|
||||
timestamp: packet.timestamp,
|
||||
salt: packet.salt,
|
||||
lastSeen: packet.previousMessages.map(msg => msg.signature || client._signatureCache[msg.id])
|
||||
})
|
||||
|
||||
if (client._lastSeenMessages.push(packet.signature) && client._lastSeenMessages.pending > 64) {
|
||||
client.write('message_acknowledgement', {
|
||||
count: client._lastSeenMessages.pending
|
||||
})
|
||||
client._lastSeenMessages.pending = 0
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const hash = crypto.createHash('sha256')
|
||||
hash.update(concat('i64', packet.salt, 'i64', packet.timestamp / 1000n, 'pstring', packet.plainMessage, 'i8', 70))
|
||||
if (packet.formattedMessage) hash.update(packet.formattedMessage)
|
||||
for (const previousMessage of packet.previousMessages) {
|
||||
hash.update(concat('i8', 70, 'UUID', previousMessage.messageSender))
|
||||
hash.update(previousMessage.messageSignature)
|
||||
if (mcData.supportFeature('chainedChatWithHashing')) {
|
||||
const hash = crypto.createHash('sha256')
|
||||
hash.update(concat('i64', packet.salt, 'i64', packet.timestamp / 1000n, 'pstring', packet.plainMessage, 'i8', 70))
|
||||
if (packet.formattedMessage) hash.update(packet.formattedMessage)
|
||||
for (const previousMessage of packet.previousMessages) {
|
||||
hash.update(concat('i8', 70, 'UUID', previousMessage.messageSender))
|
||||
hash.update(previousMessage.messageSignature)
|
||||
}
|
||||
|
||||
// Chain integrity remains even if message is considered unverified due to expiry
|
||||
const tsDelta = BigInt(Date.now()) - packet.timestamp
|
||||
const expired = !packet.timestamp || tsDelta > messageExpireTime || tsDelta < 0
|
||||
const verified = !packet.unsignedChatContent && updateAndValidateChat(packet.senderUuid, packet.previousSignature, packet.signature, hash.digest()) && !expired
|
||||
client.emit('playerChat', {
|
||||
plainMessage: packet.plainMessage,
|
||||
unsignedContent: packet.unsignedChatContent,
|
||||
formattedMessage: packet.formattedMessage,
|
||||
type: packet.type,
|
||||
sender: packet.senderUuid,
|
||||
senderName: packet.networkName,
|
||||
targetName: packet.networkTargetName,
|
||||
verified
|
||||
})
|
||||
|
||||
// We still accept a message (by pushing to seenMessages) even if the chain is broken. A vanilla client
|
||||
// will reject a message if the client sets secure chat to be required and the message from the server
|
||||
// isn't signed, or the client has blocked the sender.
|
||||
// client1.19.1/client/net/minecraft/client/multiplayer/ClientPacketListener.java#L768
|
||||
client._lastChatHistory.push({
|
||||
type: 0, // Player message
|
||||
previousSignature: packet.previousSignature,
|
||||
signature: packet.signature,
|
||||
message: {
|
||||
plain: packet.plainMessage,
|
||||
decorated: packet.formattedMessage
|
||||
},
|
||||
messageHash: packet.bodyDigest,
|
||||
timestamp: packet.timestamp,
|
||||
salt: packet.salt,
|
||||
lastSeen: packet.previousMessages
|
||||
})
|
||||
|
||||
if (client._lastSeenMessages.push({ sender: packet.senderUuid, signature: packet.signature }) && client._lastSeenMessages.pending++ > 64) {
|
||||
client.write('message_acknowledgement', {
|
||||
previousMessages: client._lastSeenMessages.map((e) => ({
|
||||
messageSender: e.sender,
|
||||
messageSignature: e.signature
|
||||
})),
|
||||
lastRejectedMessage: client._lastRejectedMessage
|
||||
})
|
||||
client._lastSeenMessages.pending = 0
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Chain integrity remains even if message is considered unverified due to expiry
|
||||
const tsDelta = BigInt(Date.now()) - packet.timestamp
|
||||
const expired = !packet.timestamp || tsDelta > messageExpireTime || tsDelta < 0
|
||||
const verified = !packet.unsignedChatContent && updateAndValidateChat(packet.senderUuid, packet.previousSignature, packet.signature, hash.digest()) && !expired
|
||||
const pubKey = client._players[packet.senderUuid]?.publicKey
|
||||
client.emit('playerChat', {
|
||||
plainMessage: packet.plainMessage,
|
||||
unsignedContent: packet.unsignedChatContent,
|
||||
formattedMessage: packet.formattedMessage,
|
||||
formattedMessage: packet.signedChatContent || packet.unsignedChatContent,
|
||||
type: packet.type,
|
||||
sender: packet.senderUuid,
|
||||
senderName: packet.networkName,
|
||||
targetName: packet.networkTargetName,
|
||||
verified
|
||||
senderName: packet.senderName,
|
||||
senderTeam: packet.senderTeam,
|
||||
verified: (pubKey && !packet.unsignedChatContent) ? client.verifyMessage(pubKey, packet) : false
|
||||
})
|
||||
|
||||
// We still accept a message (by pushing to seenMessages) even if the chain is broken. A vanilla client
|
||||
// will reject a message if the client sets secure chat to be required and the message from the server
|
||||
// isn't signed, or the client has blocked the sender.
|
||||
// client1.19.1/client/net/minecraft/client/multiplayer/ClientPacketListener.java#L768
|
||||
client._lastSeenMessages.push({ sender: packet.senderUuid, signature: packet.signature })
|
||||
client._lastChatHistory.push({
|
||||
previousSignature: packet.previousSignature,
|
||||
signature: packet.signature,
|
||||
message: {
|
||||
plain: packet.plainMessage,
|
||||
decorated: packet.formattedMessage
|
||||
},
|
||||
messageHash: packet.bodyDigest,
|
||||
timestamp: packet.timestamp,
|
||||
salt: packet.salt,
|
||||
lastSeen: packet.previousMessages
|
||||
})
|
||||
|
||||
if (client._lastSeenMessages.pending++ > 64) {
|
||||
client.write('message_acknowledgement', {
|
||||
previousMessages: client._lastSeenMessages.map((e) => ({
|
||||
messageSender: e.sender,
|
||||
messageSignature: e.signature
|
||||
})),
|
||||
lastRejectedMessage: client._lastRejectedMessage
|
||||
})
|
||||
client._lastSeenMessages.pending = 0
|
||||
}
|
||||
})
|
||||
|
||||
// Chat Sending
|
||||
|
@ -175,6 +303,38 @@ module.exports = function (client, options) {
|
|||
options.timestamp = options.timestamp || BigInt(Date.now())
|
||||
options.salt = options.salt || 1n
|
||||
|
||||
if (mcData.supportFeature('useChatSessions')) {
|
||||
let acc = 0
|
||||
const acknowledgements = []
|
||||
|
||||
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)
|
||||
message.pending = false
|
||||
}
|
||||
}
|
||||
|
||||
const bitset = Buffer.allocUnsafe(3)
|
||||
bitset[0] = acc & 0xFF
|
||||
bitset[1] = (acc >> 8) & 0xFF
|
||||
bitset[2] = (acc >> 16) & 0xFF
|
||||
|
||||
client.write('chat_message', {
|
||||
message,
|
||||
timestamp: options.timestamp,
|
||||
salt: options.salt,
|
||||
signature: client.profileKeys ? client.signMessage(message, options.timestamp, options.salt, undefined, acknowledgements) : undefined,
|
||||
offset: client._lastSeenMessages.pending,
|
||||
acknowledged: bitset
|
||||
})
|
||||
client._lastSeenMessages.pending = 0
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (options.skipPreview || !client.serverFeatures.chatPreview) {
|
||||
client.write('chat_message', {
|
||||
message,
|
||||
|
@ -207,10 +367,18 @@ module.exports = function (client, options) {
|
|||
})
|
||||
|
||||
// Signing methods
|
||||
client.signMessage = (message, timestamp, salt = 0, preview) => {
|
||||
client.signMessage = (message, timestamp, salt = 0, preview, acknowledgements) => {
|
||||
if (!client.profileKeys) throw Error("Can't sign message without profile keys, please set valid auth mode")
|
||||
|
||||
if (mcData.supportFeature('chainedChatWithHashing')) {
|
||||
if (mcData.supportFeature('useChatSessions')) {
|
||||
if (!client._session.uuid) throw Error("Chat session not initialized. Can't send chat")
|
||||
|
||||
const length = Buffer.byteLength(message, 'utf8')
|
||||
const previousMessages = acknowledgements.length > 0 ? ['i32', acknowledgements.length, 'buffer', Buffer.concat(acknowledgements)] : ['i32', 0]
|
||||
|
||||
const signable = concat('i32', 1, 'UUID', client.uuid, 'UUID', client._session.uuid, 'i32', client._session.index++, 'i64', salt, 'i64', timestamp / 1000n, 'i32', length, 'pstring', message, ...previousMessages)
|
||||
return crypto.sign('RSA-SHA256', signable, client.profileKeys.private)
|
||||
} else if (mcData.supportFeature('chainedChatWithHashing')) {
|
||||
// 1.19.2
|
||||
const signer = crypto.createSign('RSA-SHA256')
|
||||
if (client._lastChatSignature) signer.update(client._lastChatSignature)
|
||||
|
@ -290,11 +458,39 @@ module.exports = function (client, options) {
|
|||
}
|
||||
}
|
||||
|
||||
class SignatureCache extends Array {
|
||||
capacity = 128
|
||||
index = 0
|
||||
|
||||
push (e) {
|
||||
if (!e) return
|
||||
|
||||
this[this.index++] = e
|
||||
this.index %= this.capacity
|
||||
}
|
||||
}
|
||||
|
||||
class LastSeenMessagesWithInvalidation extends Array {
|
||||
capacity = 20
|
||||
offset = 0
|
||||
pending = 0
|
||||
|
||||
push (e) {
|
||||
if (!e) return false
|
||||
|
||||
this[this.offset] = { pending: true, signature: e }
|
||||
this.offset = (this.offset + 1) % this.capacity
|
||||
this.pending++
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
class LastSeenMessages extends Array {
|
||||
capacity = 5
|
||||
pending = 0
|
||||
|
||||
push (e) {
|
||||
if (e.signature.length === 0) return // We do not acknowledge unsigned messages
|
||||
if (!e) return false // We do not acknowledge unsigned messages
|
||||
|
||||
// Insert a new entry at the top and shift everything to the right
|
||||
let last = this[0]
|
||||
|
@ -308,5 +504,6 @@ class LastSeenMessages extends Array {
|
|||
if (!current || (current.sender === e.sender)) break
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
const states = require('../states')
|
||||
const signedChatPlugin = require('./chat')
|
||||
const uuid = require('uuid-1345')
|
||||
|
||||
module.exports = function (client, options) {
|
||||
client.serverFeatures = {}
|
||||
|
@ -18,6 +19,20 @@ module.exports = function (client, options) {
|
|||
client.uuid = packet.uuid
|
||||
client.username = packet.username
|
||||
|
||||
if (mcData.supportFeature('useChatSessions') && client.profileKeys) {
|
||||
client._session = {
|
||||
index: 0,
|
||||
uuid: uuid.v4fast()
|
||||
}
|
||||
|
||||
client.write('session', {
|
||||
sessionUUID: client._session.uuid,
|
||||
expireTime: client.profileKeys ? BigInt(client.profileKeys.expiresOn.getTime()) : undefined,
|
||||
publicKey: client.profileKeys ? client.profileKeys.public.export({ type: 'spki', format: 'der' }) : undefined,
|
||||
signature: client.profileKeys ? client.profileKeys.signatureV2 : undefined
|
||||
})
|
||||
}
|
||||
|
||||
if (mcData.supportFeature('signedChat')) {
|
||||
if (options.disableChatSigning && client.serverFeatures.enforcesSecureChat) {
|
||||
throw new Error('"disableChatSigning" was enabled in client options, but server is enforcing secure chat')
|
||||
|
|
|
@ -25,10 +25,9 @@ module.exports = function (client, options) {
|
|||
nextState: 2
|
||||
})
|
||||
client.state = states.LOGIN
|
||||
|
||||
client.write('login_start', {
|
||||
username: client.username,
|
||||
signature: client.profileKeys
|
||||
signature: (client.profileKeys && !mcData.supportFeature('useChatSessions'))
|
||||
? {
|
||||
timestamp: BigInt(client.profileKeys.expiresOn.getTime()), // should probably be called "expireTime"
|
||||
// Remove padding on the public key: not needed in vanilla server but matches how vanilla client looks
|
||||
|
|
2
src/index.d.ts
vendored
2
src/index.d.ts
vendored
|
@ -36,7 +36,7 @@ declare module 'minecraft-protocol' {
|
|||
registerChannel(name: string, typeDefinition: any, custom?: boolean): void
|
||||
unregisterChannel(name: string): void
|
||||
writeChannel(channel: any, params: any): void
|
||||
signMessage(message: string, timestamp: BigInt, salt?: number, preview?: string): Buffer
|
||||
signMessage(message: string, timestamp: BigInt, salt?: number, preview?: string, acknowledgements?: Buffer[]): Buffer
|
||||
verifyMessage(publicKey: Buffer | KeyObject, packet: object): boolean
|
||||
reportPlayer(uuid: string, reason: 'FALSE_REPORTING' | 'HATE_SPEECH' | 'TERRORISM_OR_VIOLENT_EXTREMISM' | 'CHILD_SEXUAL_EXPLOITATION_OR_ABUSE' | 'IMMINENT_HARM' | 'NON_CONSENSUAL_INTIMATE_IMAGERY' | 'HARASSMENT_OR_BULLYING' | 'DEFAMATION_IMPERSONATION_FALSE_INFORMATION' | 'SELF_HARM_OR_SUICIDE' | 'ALCOHOL_TOBACCO_DRUGS', signatures: Buffer[], comment?: string): Promise<true>
|
||||
on(event: 'error', listener: (error: Error) => PromiseLike): this
|
||||
|
|
|
@ -2,6 +2,7 @@ const crypto = require('crypto')
|
|||
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) {
|
||||
|
@ -39,8 +40,9 @@ function validateLastMessages (pending, 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 = new Pending()
|
||||
const pending = client.supportFeature('useChatSessions') ? new LastSeenMessages() : new Pending()
|
||||
|
||||
if (!options.generatePreview) options.generatePreview = message => message
|
||||
|
||||
|
@ -57,6 +59,44 @@ module.exports = function (client, server, options) {
|
|||
}
|
||||
}
|
||||
|
||||
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('session', (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) => {
|
||||
|
@ -67,19 +107,30 @@ module.exports = function (client, server, options) {
|
|||
}
|
||||
lastTimestamp = packet.timestamp
|
||||
|
||||
// Checks here: 1) make sure client can chat, 2) chain is OK, 3) signature is OK, 4) log if expired
|
||||
// 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.verifyMessage(packet)) raise('multiplayer.disconnect.unsigned_chat')
|
||||
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', validateMessageChain)
|
||||
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('chainedChatWithHashing')) { // 1.19.1+
|
||||
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)
|
||||
|
@ -124,6 +175,53 @@ module.exports = function (client, server, options) {
|
|||
}
|
||||
}
|
||||
|
||||
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 = []
|
||||
|
|
|
@ -60,7 +60,7 @@ module.exports = function (client, server, options) {
|
|||
|
||||
try {
|
||||
const publicKey = crypto.createPublicKey({ key: packet.signature.publicKey, format: 'der', type: 'spki' })
|
||||
const signable = mcData.supportFeature('chainedChatWithHashing')
|
||||
const signable = mcData.supportFeature('profileKeySignatureV2')
|
||||
? concat('UUID', packet.playerUUID, 'i64', packet.signature.timestamp, 'buffer', publicKey.export({ type: 'spki', format: 'der' }))
|
||||
: Buffer.from(packet.signature.timestamp + mcPubKeyToPem(packet.signature.publicKey), 'utf8') // (expires at + publicKey)
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
'use strict'
|
||||
|
||||
module.exports = {
|
||||
defaultVersion: '1.19.2',
|
||||
supportedVersions: ['1.7', '1.8', '1.9', '1.10', '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']
|
||||
defaultVersion: '1.19.3',
|
||||
supportedVersions: ['1.7', '1.8', '1.9', '1.10', '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']
|
||||
}
|
||||
|
|
|
@ -105,7 +105,7 @@ for (const supportedVersion of mc.supportedVersions) {
|
|||
}))
|
||||
client.on('error', err => done(err))
|
||||
const lineListener = function (line) {
|
||||
const match = line.match(/\[Server thread\/INFO\]: <(.+?)> (.+)/)
|
||||
const match = line.match(/\[Server thread\/INFO\]: (?:\[Not Secure\] )?<(.+?)> (.+)/)
|
||||
if (!match) return
|
||||
assert.strictEqual(match[1], 'Player')
|
||||
assert.strictEqual(match[2], 'hello everyone; I have logged in.')
|
||||
|
@ -119,11 +119,62 @@ for (const supportedVersion of mc.supportedVersions) {
|
|||
client.chat('hello everyone; I have logged in.')
|
||||
})
|
||||
|
||||
// 1.18 and below
|
||||
client.on('chat', function (packet) {
|
||||
client.on('playerChat', function (data) {
|
||||
chatCount += 1
|
||||
assert.ok(chatCount <= 2)
|
||||
const message = JSON.parse(packet.message)
|
||||
|
||||
if (!mcData.supportFeature('clientsideChatFormatting')) {
|
||||
const message = JSON.parse(data.formattedMessage)
|
||||
if (chatCount === 1) {
|
||||
assert.strictEqual(message.translate, 'chat.type.text')
|
||||
assert.deepEqual(message.with[0].clickEvent, {
|
||||
action: 'suggest_command',
|
||||
value: mcData.version.version > 340 ? '/tell Player ' : '/msg Player '
|
||||
})
|
||||
assert.deepEqual(message.with[0].text, 'Player')
|
||||
assert.strictEqual(message.with[1], 'hello everyone; I have logged in.')
|
||||
} else if (chatCount === 2) {
|
||||
assert.strictEqual(message.translate, 'chat.type.announcement')
|
||||
assert.strictEqual(message.with[0].text ? message.with[0].text : message.with[0], 'Server')
|
||||
assert.deepEqual(message.with[1].extra
|
||||
? (message.with[1].extra[0].text
|
||||
? message.with[1].extra[0].text
|
||||
: message.with[1].extra[0])
|
||||
: message.with[1].text, 'hello')
|
||||
wrap.removeListener('line', lineListener)
|
||||
client.end()
|
||||
done()
|
||||
}
|
||||
} else {
|
||||
// 1.19+
|
||||
|
||||
const message = JSON.parse(data.formattedMessage || JSON.stringify({ text: data.plainMessage }))
|
||||
|
||||
if (chatCount === 1) {
|
||||
assert.strictEqual(message.text, 'hello everyone; I have logged in.')
|
||||
const sender = JSON.parse(data.senderName)
|
||||
assert.deepEqual(sender.clickEvent, {
|
||||
action: 'suggest_command',
|
||||
value: '/tell Player '
|
||||
})
|
||||
assert.strictEqual(sender.text, 'Player')
|
||||
} else if (chatCount === 2) {
|
||||
assert.strictEqual(message.text, 'hello')
|
||||
const sender = JSON.parse(data.senderName)
|
||||
assert.strictEqual(sender.text, 'Server')
|
||||
wrap.removeListener('line', lineListener)
|
||||
client.end()
|
||||
done()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
client.on('systemChat', function (data) {
|
||||
// For 1.7.10
|
||||
chatCount += 1
|
||||
assert.ok(chatCount <= 2)
|
||||
|
||||
const message = JSON.parse(data.formattedMessage)
|
||||
if (chatCount === 1) {
|
||||
assert.strictEqual(message.translate, 'chat.type.text')
|
||||
assert.deepEqual(message.with[0].clickEvent, {
|
||||
|
@ -145,22 +196,6 @@ for (const supportedVersion of mc.supportedVersions) {
|
|||
done()
|
||||
}
|
||||
})
|
||||
|
||||
// 1.19 and above
|
||||
let gotClientMessage, gotServerMessage
|
||||
client.on('player_chat', (packet) => {
|
||||
const message = JSON.parse(packet.unsignedChatContent || packet.signedChatContent)
|
||||
// const sender = JSON.parse(packet.senderName)
|
||||
|
||||
if (message.text === 'hello everyone; I have logged in.') gotClientMessage = true
|
||||
if (message.text === 'hello') gotServerMessage = true
|
||||
|
||||
if (gotClientMessage && gotServerMessage) {
|
||||
wrap.removeListener('line', lineListener)
|
||||
client.end()
|
||||
done()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('does not crash for ' + SURVIVE_TIME + 'ms', function (done) {
|
||||
|
|
|
@ -1,21 +1,20 @@
|
|||
module.exports = client => {
|
||||
const mcData = require('minecraft-data')(client.version)
|
||||
const hasSignedChat = mcData.supportFeature('signedChat')
|
||||
|
||||
client.nextMessage = (containing) => {
|
||||
return new Promise((resolve) => {
|
||||
function onChat (packet) {
|
||||
const m = packet.message || packet.unsignedChatContent || packet.signedChatContent
|
||||
const m = packet.formattedMessage || packet.unsignedChatContent || JSON.stringify({ text: packet.plainMessage })
|
||||
if (containing) {
|
||||
if (m.includes(containing)) return finish(m)
|
||||
else return
|
||||
}
|
||||
return finish(m)
|
||||
}
|
||||
client.on(hasSignedChat ? 'player_chat' : 'chat', onChat)
|
||||
client.on('playerChat', onChat)
|
||||
client.on('systemChat', onChat) // For 1.7.10
|
||||
|
||||
function finish (m) {
|
||||
client.off(hasSignedChat ? 'player_chat' : 'chat', onChat)
|
||||
client.off('playerChat', onChat)
|
||||
client.off('systemChat', onChat)
|
||||
resolve(m)
|
||||
}
|
||||
})
|
||||
|
|
|
@ -44,7 +44,20 @@ const values = {
|
|||
i8: -10,
|
||||
u8: 8,
|
||||
string: 'hi hi this is my client string',
|
||||
buffer: Buffer.alloc(8),
|
||||
buffer: function (typeArgs, context) {
|
||||
let count
|
||||
if (typeof typeArgs.count === 'number') {
|
||||
count = typeArgs.count
|
||||
} else if (typeof typeArgs.count === 'object') {
|
||||
count = evalCount(typeArgs.count, context)
|
||||
} else if (typeArgs.count !== undefined) {
|
||||
count = getField(typeArgs.count, context)
|
||||
} else if (typeArgs.countType !== undefined) {
|
||||
count = 8
|
||||
}
|
||||
|
||||
return Buffer.alloc(count)
|
||||
},
|
||||
array: function (typeArgs, context) {
|
||||
let count
|
||||
if (typeof typeArgs.count === 'number') {
|
||||
|
@ -114,6 +127,7 @@ const values = {
|
|||
test7: { type: 'intArray', value: [12, 42] }
|
||||
}
|
||||
},
|
||||
previousMessages: [],
|
||||
compressedNbt: {
|
||||
type: 'compound',
|
||||
name: 'test',
|
||||
|
|
|
@ -75,6 +75,7 @@ for (const supportedVersion of mc.supportedVersions) {
|
|||
function sendBroadcastMessage(server, clients, message, sender) {
|
||||
if (mcData.supportFeature('signedChat')) {
|
||||
server.writeToClients(clients, 'player_chat', {
|
||||
plainMessage: message,
|
||||
signedChatContent: '',
|
||||
unsignedChatContent: JSON.stringify({ text: message }),
|
||||
type: 0,
|
||||
|
@ -83,7 +84,10 @@ for (const supportedVersion of mc.supportedVersions) {
|
|||
senderTeam: undefined,
|
||||
timestamp: Date.now(),
|
||||
salt: 0n,
|
||||
signature: Buffer.alloc(0)
|
||||
signature: mcData.supportFeature('useChatSessions') ? undefined : Buffer.alloc(0),
|
||||
previousMessages: [],
|
||||
filterType: 0,
|
||||
networkName: JSON.stringify({ text: sender })
|
||||
})
|
||||
} else {
|
||||
server.writeToClients(clients, 'chat', { message: JSON.stringify({ text: message }), position: 0, sender: sender || '0' })
|
||||
|
@ -318,6 +322,7 @@ for (const supportedVersion of mc.supportedVersions) {
|
|||
version: version.minecraftVersion,
|
||||
port: PORT
|
||||
}))
|
||||
|
||||
player1.on('login', async function (packet) {
|
||||
assert.strictEqual(packet.gameMode, 1)
|
||||
const player2 = applyClientHelpers(mc.createClient({
|
||||
|
@ -328,6 +333,7 @@ for (const supportedVersion of mc.supportedVersions) {
|
|||
}))
|
||||
|
||||
const p1Join = await player1.nextMessage('player2')
|
||||
|
||||
assert.strictEqual(p1Join, '{"text":"player2 joined the game."}')
|
||||
|
||||
player2.chat('hi')
|
||||
|
|
Loading…
Reference in a new issue