Initial 1.19.1/2 signed chat support (#1050)

* Initial 1.19.1/2 signed chat impl

* lint

* remove node 15 nullish operators

* fix undefined uuid error

* handle player left

* fix

* add some feature flags

* fix

* Fix test to use new client.chat() wrapper method

* refactoring

* corrections, working client example

* refactoring

* message expiry checking

* Fix UUID write serialization

* Remove padding from client login to match vanilla client

* Fix server verification

* update packet field terminology

* Add some tech docs
Rename `map` field in Pending to not conflict with Array.map method

* update tech doc

* lint

* Bump mcdata and pauth

* add doc on playerChat event, .chat function

* update doc

* use supportFeature, update doc

Co-authored-by: Romain Beaumont <romain.rom1@gmail.com>
This commit is contained in:
extremeheat 2023-01-14 14:33:04 -05:00 committed by GitHub
parent 1efbde1ef7
commit 367c01567c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 615 additions and 95 deletions

View file

@ -45,6 +45,10 @@ Write a packet to all `clients` but encode it only once.
Verifies if player's chat message packet was signed with their Mojang provided key
### client.logSentMessageFromPeer(packet)
(1.19.1+) You must call this function when the server receives a message from a player and that message gets
broadcast to other players in player_chat packets. This function stores these packets so the server can then verify a player's lastSeenMessages field in inbound chat packets to ensure chain integrity. For more information, see [chat.md](chat.md).
### server.onlineModeExceptions
This is a plain old JavaScript object. Add a key with the username you want to
@ -253,6 +257,18 @@ parameters.
Called when an error occurs within the client. Takes an Error as parameter.
### `playerChat` event
Called when a chat message from another player arrives. The emitted object contains:
* formattedMessage -- the chat message preformatted, if done on server side
* message -- the chat message without formatting (for example no `<username> message` ; instead `message`), on version 1.19+
* type -- the message type - on 1.19, which format string to use to render message ; below, the place where the message is displayed (for example chat or action bar)
* sender -- the UUID of the player sending the message
* senderTeam -- scoreboard team of the player (pre 1.19)
* verified -- true if message is signed, false if not signed, undefined on versions prior to 1.19
See the [chat example](https://github.com/PrismarineJS/node-minecraft-protocol/blob/master/examples/client_chat/client_chat.js#L1) for usage.
### per-packet events
Check out the [minecraft-data docs](https://prismarinejs.github.io/minecraft-data/?v=1.8&d=protocol) to know the event names and data field names.
@ -273,13 +289,16 @@ Start emitting channel events of the given name on the client object.
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
Generate a signature for a chat message to be sent to server
(1.19) Generate a signature for a chat message to be sent to server
### client.verifyMessage(publicKey: Buffer | KeyObject, packet) : boolean
Verifies a player chat packet sent by another player against their public key
(1.19) Verifies a player chat packet sent by another player against their public key
## Not Immediately Obvious Data Type Formats

54
docs/chat.md Normal file
View file

@ -0,0 +1,54 @@
## About chat signing
Starting in Minecraft 1.19, client messages sent to the server are signed and then broadcasted to other players.
Other clients receiving a signed message can verify that a message was written by a particular player as opposed
to being modified by the server. The way this is achieved is by the client asking Mojang's servers for signing keys,
and the server responding with a private key that can be used to sign messages, and a public key that can be used to
verify the messages.
When a client connects to the server, it sends its public key to the server, which then sends that to other players
that are on the server. The server also does some checks during the login procedure to authenticate the validity of
the public key, to ensure it came from Mojang. This is achieved by the client sending along a signature from Mojang's
servers in the login step which is the output of concatenating and signing the public key, player UUID and timestamp
with a special Mojang private key specifically for signature validation. The public key used to verify this
signature is public and is stored statically inside node-minecraft-protocol (src/server/constants.js).
Back to the client, when other players join the server they also get a copy of the players' public key for chat verification.
The clients can then verify that a message came from a client as well as do secondary checks like verifying timestamps.
This feature is designed to allow players to report chat messages from other players to Mojang. When the client reports a
message the contents, the sender UUID, timestamp, and signature are all sent so the Mojang server can verify the message
and send it for moderator review.
Note: Since the server sends the public key, it's possible that the server can spoof the key and return a fake one, so
only Mojang can truly know if a message came from a client (as it stores its own copy of the clients' chat key pair).
## 1.19.1
Starting with 1.19.1, instead of signing the message itself, a SHA256 hash of the message and last seen messages are
signed instead. In addition, the payload of the hash is prepended with the signature of the previous message sent by the same client,
creating a signed chain of chat messages. See publicly available documentation for more detailed information on this.
Since chat verification happens on the client-side (as well as server side), all clients need to be kept up to date
on messages from other users. Since not all messages are public (for example, a player may send a signed private message),
the server can send a `chat_header` packet containing the aforementioned SHA256 hash of the message which the client
can generate a signature from, and store as the last signature for that player (maintaining chain integrity).
In the client, inbound player chat history is now stored in chat logs (in a 1000 length array). This allows players
to search through last seen messages when reporting messages.
When reporting chat messages, the chained chat functionality and chat history also securely lets Mojang get
authentic message context before and after a reported message.
## Extra details
### 1.19.1
When a server sends a player a message from another player, the server saves the outbound message and expects
that the client will acknowledge that message, either in a outbound `chat_message` packet's lastSeen field,
or in a `message_acknowledgement` packet. (If the client doesn't seen any chat_message's to the server and
lots of messages pending ACK queue up, a serverbound `message_acknowledgement` packet will be sent to flush the queue.)
In the server, upon reviewal of the ACK, those messages removed from the servers' pending array. If too many
pending messages pile up, the client will get kicked.
In nmp server, you must call `client.logSentMessageFromPeer(packet)` when the server receives a message from a player and that message gets broadcast to other players in player_chat packets. This function stores these packets so the server can then verify a player's lastSeenMessages field in inbound chat packets to ensure chain integrity (as described above).

View file

@ -3,7 +3,8 @@ const readline = require('readline')
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
terminal: false
terminal: false,
prompt: 'Enter a message> '
})
const [,, host, port, username] = process.argv
@ -37,47 +38,14 @@ client.on('error', function (err) {
})
client.on('connect', () => {
const mcData = require('minecraft-data')(client.version)
const ChatMessage = require('prismarine-chat')(client.version)
const players = {} // 1.19+
console.log('Connected to server')
rl.prompt()
client.chat = (message) => {
if (mcData.supportFeature('signedChat')) {
const timestamp = BigInt(Date.now())
client.write('chat_message', {
message,
timestamp,
salt: 0,
signature: client.signMessage(message, timestamp)
})
} else {
client.write('chat', { message })
}
}
function onChat (packet) {
const message = packet.message || packet.unsignedChatContent || packet.signedChatContent
const j = JSON.parse(message)
const chat = new ChatMessage(j)
if (packet.signature) {
const verified = client.verifyMessage(players[packet.senderUuid].publicKey, packet)
console.info(verified ? 'Verified: ' : 'UNVERIFIED: ', chat.toAnsi())
} else {
console.info(chat.toAnsi())
}
}
client.on('chat', onChat)
client.on('player_chat', onChat)
client.on('player_info', (packet) => {
if (packet.action === 0) { // add player
for (const player of packet.data) {
players[player.UUID] = player.crypto
}
}
client.on('playerChat', function ({ senderName, message, formattedMessage, verified }) {
const chat = new ChatMessage(formattedMessage ? JSON.parse(formattedMessage) : message)
console.log(senderName, { true: 'Verified:', false: 'UNVERIFIED:' }[verified] || '', chat.toAnsi())
})
})

View file

@ -51,11 +51,11 @@
"endian-toggle": "^0.0.0",
"lodash.get": "^4.1.2",
"lodash.merge": "^4.3.0",
"minecraft-data": "^3.8.0",
"minecraft-data": "^3.21.0",
"minecraft-folder-path": "^1.2.0",
"node-fetch": "^2.6.1",
"node-rsa": "^0.4.2",
"prismarine-auth": "^2.0.0",
"prismarine-auth": "^2.2.0",
"prismarine-nbt": "^2.0.0",
"protodef": "^1.8.0",
"readable-stream": "^4.1.0",

287
src/client/chat.js Normal file
View file

@ -0,0 +1,287 @@
const crypto = require('crypto')
const concat = require('../transforms/binaryStream').concat
const messageExpireTime = 420000 // 7 minutes (ms)
module.exports = function (client, options) {
const mcData = require('minecraft-data')(client.version)
client._players = {}
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 last 1024 inbound messages for report lookup
client._lastChatHistory = new class extends Array {
capacity = 1024
push (e) {
super.push(e)
if (this.length > this.capacity) {
this.shift()
}
}
}()
function updateAndValidateChat (uuid, previousSignature, currentSignature, payload) {
// Get the player information
const player = client._players[uuid]
if (player && player.hasChainIntegrity) {
if (!player.lastSignature) {
// First time client is handling a chat message from this player, allow
player.lastSignature = currentSignature
} else if (player.lastSignature.equals(previousSignature)) {
player.lastSignature = currentSignature
} else {
// Not valid, client can no longer authenticate messages until player quits and reconnects
player.hasChainIntegrity = false
}
if (player.hasChainIntegrity) {
const verifier = crypto.createVerify('RSA-SHA256')
if (previousSignature) verifier.update(previousSignature)
verifier.update(concat('UUID', uuid))
verifier.update(payload)
player.hasChainIntegrity = verifier.verify(player.publicKey, currentSignature)
}
return player.hasChainIntegrity
}
return false
}
client.on('player_info', (packet) => {
if (packet.action === 0) { // add player
for (const player of packet.data) {
if (player.crypto) {
client._players[player.UUID] = {
publicKey: crypto.createPublicKey({ key: player.crypto.publicKey, format: 'der', type: 'spki' }),
publicKeyDER: player.crypto.publicKey,
signature: player.crypto.signature,
displayName: player.displayName || player.name
}
client._players[player.UUID].hasChainIntegrity = true
}
}
} else if (packet.action === 4) { // remove player
for (const player of packet.data) {
delete client._players[player.UUID]
}
}
})
client.on('message_header', (packet) => {
updateAndValidateChat(packet.senderUuid, packet.previousSignature, packet.signature, packet.messageHash)
client._lastChatHistory.push({
previousSignature: packet.previousSignature,
signature: packet.signature,
messageHash: packet.messageHash
})
})
client.on('player_chat', (packet) => {
if (!mcData.supportFeature('chainedChatWithHashing')) { // 1.19.0
const pubKey = client._players[packet.senderUuid]?.publicKey
client.emit('playerChat', {
formattedMessage: packet.signedChatContent || packet.unsignedChatContent,
type: packet.type,
sender: packet.senderUuid,
senderName: packet.senderName,
senderTeam: packet.senderTeam,
verified: pubKey ? client.verifyMessage(pubKey, packet) : false
})
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)
}
// Chain integrity remains even if message is considered unverified due to expiry
const tsDelta = Date.now() - packet.timestamp
const expired = !packet.timestamp || tsDelta > messageExpireTime || tsDelta < 0
const verified = updateAndValidateChat(packet.senderUuid, packet.previousSignature, packet.signature, hash.digest()) && !expired
client.emit('playerChat', {
message: packet.plainMessage || packet.unsignedChatContent,
formattedMessage: packet.formattedMessage,
type: packet.type,
sender: packet.senderUuid,
senderName: client._players[packet.senderUuid]?.displayName,
senderTeam: packet.senderTeam,
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._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
let pendingChatRequest
let lastPreviewRequestId = 0
client._signedChat = (message, options = {}) => {
options.timestamp = options.timestamp || BigInt(Date.now())
options.salt = options.salt || 0
if (options.skipPreview || !client.serverFeatures.chatPreview) {
client.write('chat_message', {
message,
timestamp: options.timestamp,
salt: options.salt,
signature: client.profileKeys ? client.signMessage(message, options.timestamp, options.salt) : Buffer.alloc(0),
signedPreview: options.didPreview,
previousMessages: client._lastSeenMessages.map((e) => ({
messageSender: e.sender,
messageSignature: e.signature
})),
lastRejectedMessage: client._lastRejectedMessage
})
client._lastSeenMessages.pending = 0
} else {
client.write('chat_preview', {
query: lastPreviewRequestId,
message
})
pendingChatRequest = { id: lastPreviewRequestId, message, options }
lastPreviewRequestId++
}
}
client.on('chat_preview', (packet) => {
if (pendingChatRequest && pendingChatRequest.id === packet.query) {
client._signedChat(packet.message, { ...pendingChatRequest.options, skipPreview: true, didPreview: true })
pendingChatRequest = null
}
})
// Signing methods
client.signMessage = (message, timestamp, salt = 0) => {
if (!client.profileKeys) throw Error("Can't sign message without profile keys, please set valid auth mode")
if (mcData.supportFeature('chainedChatWithHashing')) {
// 1.19.2
const signer = crypto.createSign('RSA-SHA256')
if (client._lastChatSignature) signer.update(client._lastChatSignature)
signer.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 (message instanceof Buffer) {
signer.update(message)
} else {
const hash = crypto.createHash('sha256')
hash.update(concat('i64', salt, 'i64', timestamp / 1000n, 'pstring', message, 'i8', 70))
for (const previousMessage of client._lastSeenMessages) {
hash.update(concat('i8', 70, 'UUID', previousMessage.sender))
hash.update(previousMessage.signature)
}
// Feed hash back into signing payload
signer.update(hash.digest())
}
client._lastChatSignature = signer.sign(client.profileKeys.private)
} else {
// 1.19
const signable = concat('i64', salt, 'UUID', client.uuid, 'i64', timestamp / 1000n, 'pstring', JSON.stringify({ text: message }))
client._lastChatSignature = crypto.sign('RSA-SHA256', signable, client.profileKeys.private)
}
return client._lastChatSignature
}
client.verifyMessage = (pubKey, packet) => {
if (!mcData.supportFeature('chainedChatWithHashing')) { // 1.19.0
// Verification handled internally in 1.19.1+ as previous messages must be stored to verify future messages
throw new Error("Please listen to the 'playerChat' event instead to check message validity. client.verifyMessage is deprecated and only works on version 1.19.")
}
if (pubKey instanceof Buffer) pubKey = crypto.createPublicKey({ key: pubKey, format: 'der', type: 'spki' })
const signable = concat('i64', packet.salt, 'UUID', packet.senderUuid, 'i64', packet.timestamp / 1000n, 'pstring', packet.signedChatContent)
return crypto.verify('RSA-SHA256', signable, pubKey, packet.signature)
}
// Report a chat message.
client.reportPlayer = (uuid, reason, reportedSignatures, comments) => {
const evidence = []
function addEvidence (entry) {
evidence.push({
previousHeaderSignature: entry.previousSignature,
uuid: entry.senderUuid,
message: entry.message,
messageHash: entry.messageHash,
signature: entry.signature,
timestamp: entry.timestamp,
salt: entry.salt,
lastSeen: entry.lastSeen
})
}
for (let i = 0; i < client._lastChatHistory.capacity; i++) {
const entry = client._lastChatHistory[i]
for (const reportSig of reportedSignatures) {
if (reportSig.equals(entry.signature)) addEvidence(entry)
}
}
return client.authflow.mca.reportPlayerChat({
reason,
comments,
messages: evidence,
reportedPlayer: uuid,
createdTime: Date.now(),
clientVersion: client.version,
serverAddress: options.host + ':' + options.port,
realmInfo: undefined // { realmId, slotId }
})
}
}
class LastSeenMessages extends Array {
capacity = 5
pending = 0
push (e) {
// Insert a new entry at the top and shift everything to the right
let last = this[0]
this[0] = e
if (last && last.sender !== e.sender) {
for (let i = 1; i < this.capacity; i++) {
const current = this[i]
this[i] = last
last = current
// If we found an existing entry for the sender ID, we can stop shifting
if (!current || (current.sender === e.sender)) break
}
}
}
}

View file

@ -14,8 +14,8 @@ async function authenticate (client, options) {
options.flow = 'live'
}
const Authflow = new PrismarineAuth(options.username, options.profilesFolder, options, options.onMsaCode)
const { token, entitlements, profile, certificates } = await Authflow.getMinecraftJavaToken({ fetchProfile: true, fetchCertificates: !options.disableChatSigning }).catch(e => {
client.authflow = new PrismarineAuth(options.username, options.profilesFolder, options, options.onMsaCode)
const { token, entitlements, profile, certificates } = await client.authflow.getMinecraftJavaToken({ fetchProfile: true, fetchCertificates: !options.disableChatSigning }).catch(e => {
if (options.password) console.warn('Sign in failed, try removing the password field\n')
if (e.toString().includes('Not Found')) console.warn(`Please verify that the account ${options.username} owns Minecraft\n`)
throw e

View file

@ -1,25 +1,43 @@
const states = require('../states')
const crypto = require('crypto')
const concat = require('../transforms/binaryStream').concat
const signedChatPlugin = require('./chat')
module.exports = function (client, options) {
client.serverFeatures = {}
client.on('server_data', (packet) => {
client.serverFeatures = {
chatPreview: packet.previewsChat,
enforcesSecureChat: packet.enforcesSecureChat
}
})
client.once('success', onLogin)
function onLogin (packet) {
const mcData = require('minecraft-data')(client.version)
client.state = states.PLAY
client.uuid = packet.uuid
client.username = packet.username
client.signMessage = (message, timestamp, salt = 0) => {
if (!client.profileKeys) throw Error("Can't sign message without profile keys, please set valid auth mode")
const signable = concat('i64', salt, 'UUID', client.uuid, 'i64',
timestamp / 1000n, 'pstring', JSON.stringify({ text: message }))
return crypto.sign('RSA-SHA256', signable, client.profileKeys.private)
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')
}
signedChatPlugin(client, options)
} else {
client.on('chat', (packet) => {
client.emit(packet.position === 0 ? 'playerChat' : 'systemChat', {
formattedMessage: packet.message,
sender: packet.sender,
positionId: packet.position,
verified: false
})
})
}
client.verifyMessage = (pubKey, packet) => {
if (pubKey instanceof Buffer) pubKey = crypto.createPublicKey({ key: pubKey, format: 'der', type: 'spki' })
const signable = concat('i64', packet.salt, 'UUID', packet.senderUuid,
'i64', packet.timestamp / 1000n, 'pstring', packet.signedChatContent)
return crypto.verify('RSA-SHA256', signable, pubKey, packet.signature)
function unsignedChat (message) {
client.write('chat', { message })
}
client.chat = client._signedChat || unsignedChat
}
}

View file

@ -13,6 +13,7 @@ module.exports = function (client, options) {
}
function next () {
const mcData = require('minecraft-data')(client.version)
let taggedHost = options.host
if (client.tagHost) taggedHost += client.tagHost
if (options.fakeHost) taggedHost = options.fakeHost
@ -24,15 +25,20 @@ module.exports = function (client, options) {
nextState: 2
})
client.state = states.LOGIN
client.write('login_start', {
username: client.username,
signature: client.profileKeys
? {
timestamp: BigInt(client.profileKeys.expiresOn.getTime()), // should probably be called "expireTime"
publicKey: client.profileKeys.publicDER,
signature: client.profileKeys.signature
// Remove padding on the public key: not needed in vanilla server but matches how vanilla client looks
publicKey: client.profileKeys.public.export({ type: 'spki', format: 'der' }),
signature: mcData.supportFeature('profileKeySignatureV2')
? client.profileKeys.signatureV2
: client.profileKeys.signature
}
: null
: null,
playerUUID: client.session?.selectedProfile?.id
})
}
}

View file

@ -43,6 +43,7 @@ function createServer (options = {}) {
server.playerCount = 0
server.onlineModeExceptions = Object.create(null)
server.favicon = favicon
server.options = options
// The RSA keypair can take some time to generate
// and is only needed for online-mode

View file

@ -47,7 +47,7 @@ module.exports = {
Write: {
varlong: ['native', minecraft.varlong[1]],
UUID: ['native', (value, buffer, offset) => {
const buf = UUID.parse(value)
const buf = value.length === 32 ? Buffer.from(value, 'hex') : UUID.parse(value)
buf.copy(buffer, offset)
return offset + 16
}],

View file

@ -38,7 +38,7 @@ function readUUID (buffer, offset) {
}
function writeUUID (value, buffer, offset) {
const buf = UUID.parse(value)
const buf = value.length === 32 ? Buffer.from(value, 'hex') : UUID.parse(value)
buf.copy(buffer, offset)
return offset + 16
}

14
src/index.d.ts vendored
View file

@ -37,6 +37,7 @@ declare module 'minecraft-protocol' {
writeChannel(channel: any, params: any): void
signMessage(message: string, timestamp: BigInt, salt?: number): 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)
on(event: 'error', listener: (error: Error) => PromiseLike): this
on(event: 'packet', handler: (data: any, packetMeta: PacketMeta, buffer: Buffer, fullBuffer: Buffer) => PromiseLike): this
on(event: 'raw', handler: (buffer: Buffer, packetMeta: PacketMeta) => PromiseLike): this
@ -46,6 +47,7 @@ declare module 'minecraft-protocol' {
on(event: 'connect', handler: () => PromiseLike): this
on(event: string, handler: (data: any, packetMeta: PacketMeta) => PromiseLike): this
on(event: `raw.${string}`, handler: (buffer: Buffer, packetMeta: PacketMeta) => PromiseLike): this
on(event: 'playerChat', handler: ({ formattedMessage, message, type, sender, senderName, senderTeam, verified }))
once(event: 'error', listener: (error: Error) => PromiseLike): this
once(event: 'packet', handler: (data: any, packetMeta: PacketMeta, buffer: Buffer, fullBuffer: Buffer) => PromiseLike): this
once(event: 'raw', handler: (buffer: Buffer, packetMeta: PacketMeta) => PromiseLike): this
@ -137,7 +139,7 @@ declare module 'minecraft-protocol' {
constructor(version: string, customPackets?: any)
writeToClients(clients: Client[], name: string, params: any): void
onlineModeExceptions: object
clients: ClientsMap
clients: { [key: number]: ServerClient }
playerCount: number
maxPlayers: number
motd: string
@ -156,6 +158,10 @@ declare module 'minecraft-protocol' {
export interface ServerClient extends Client {
id: number
// You must call this function when the server receives a message from a player and that message gets
// broadcast to other players in player_chat packets. This function stores these packets so the server
// can then verify a player's lastSeenMessages field in inbound chat packets to ensure chain integrity.
logSentMessageFromPeer(packet: object): boolean
}
export interface ServerOptions {
@ -181,6 +187,8 @@ declare module 'minecraft-protocol' {
// 1.19+
// Require connecting clients to have chat signing support enabled
enforceSecureProfile?: boolean
// 1.19.1 & 1.19.2 only: If client should send previews of messages they are typing to the server
enableChatPreview?: boolean
}
export interface SerializerOptions {
@ -211,10 +219,6 @@ declare module 'minecraft-protocol' {
state: States
}
interface ClientsMap {
[key: number]: Client
}
export interface PingOptions {
host?: string
majorVersion?: string

166
src/server/chat.js Normal file
View file

@ -0,0 +1,166 @@
const crypto = require('crypto')
const concat = require('../transforms/binaryStream').concat
const debug = require('debug')('minecraft-protocol')
const messageExpireTime = 300000 // 5 min (ms)
class VerificationError extends Error {}
function validateLastMessages (pending, lastSeen, lastRejected) {
if (lastRejected) {
const rejectedTs = pending.get(lastRejected.sender, lastRejected.signature)
if (!rejectedTs) {
throw new VerificationError(`Client rejected a message we never sent from '${lastRejected.sender}'`)
} else {
pending.acknowledge(lastRejected.sender, lastRejected.signature)
}
}
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 raise = (translatableError) => client.end(translatableError, JSON.stringify({ translate: translatableError }))
const pending = new Pending()
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)
}
}
}
// 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
// Checks here: 1) make sure client can chat, 2) chain 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 ((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.verifyMessage = (packet) => {
if (!client.profileKeys) return null
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))
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 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)
}
}

View file

@ -3,8 +3,10 @@ const crypto = require('crypto')
const pluginChannels = require('../client/pluginChannels')
const states = require('../states')
const yggdrasil = require('yggdrasil')
const chatPlugin = require('./chat')
const { concat } = require('../transforms/binaryStream')
const { mojangPublicKeyPem } = require('./constants')
const debug = require('debug')('minecraft-protocol')
module.exports = function (client, server, options) {
const mojangPubKey = crypto.createPublicKey(mojangPublicKeyPem)
@ -13,7 +15,10 @@ module.exports = function (client, server, options) {
const {
'online-mode': onlineMode = true,
kickTimeout = 30 * 1000,
errorHandler: clientErrorHandler = (client, err) => client.end(err)
errorHandler: clientErrorHandler = function (client, err) {
if (!options.hideErrors) console.debug('Disconnecting client because error', err)
client.end(err)
}
} = options
let serverId
@ -33,6 +38,7 @@ module.exports = function (client, server, options) {
function onLogin (packet) {
const mcData = require('minecraft-data')(client.version)
client.supportFeature = mcData.supportFeature
client.username = packet.username
const isException = !!server.onlineModeExceptions[client.username.toLowerCase()]
@ -47,21 +53,26 @@ module.exports = function (client, server, options) {
if (packet.signature) {
if (packet.signature.timestamp < BigInt(Date.now())) {
debug('Client sent expired tokens')
raise('multiplayer.disconnect.invalid_public_key_signature')
return // expired tokens, client needs to restart game
}
try {
const publicKey = crypto.createPublicKey({ key: packet.signature.publicKey, format: 'der', type: 'spki' })
const publicPEM = mcPubKeyToPem(packet.signature.publicKey)
const signable = packet.signature.timestamp + publicPEM // (expires at + publicKey)
const signable = mcData.supportFeature('chainedChatWithHashing')
? 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)
if (!crypto.verify('RSA-SHA1', Buffer.from(signable, 'utf8'), mojangPubKey, packet.signature.signature)) {
// 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.signature)) {
debug('Signature mismatch')
raise('multiplayer.disconnect.invalid_public_key_signature')
return
}
client.profileKeys = { public: publicKey, publicPEM }
client.profileKeys = { public: publicKey }
} catch (err) {
debug(err)
raise('multiplayer.disconnect.invalid_public_key')
return
}
@ -186,6 +197,14 @@ module.exports = function (client, server, options) {
})
// TODO: find out what properties are on 'success' packet
client.state = states.PLAY
client.settings = {}
if (client.supportFeature('chainedChatWithHashing')) { // 1.19.1+
client.write('server_data', {
previewsChat: options.enableChatPreview,
enforceSecureProfile: options.enforceSecureProfile
})
}
clearTimeout(loginKickTimer)
loginKickTimer = null
@ -195,15 +214,7 @@ module.exports = function (client, server, options) {
server.playerCount -= 1
})
pluginChannels(client, options)
if (client.profileKeys) {
client.verifyMessage = (packet) => {
const signable = concat('i64', packet.salt, 'UUID', client.uuid, 'i64',
packet.timestamp, 'pstring', packet.message)
return crypto.verify('sha256WithRSAEncryption', signable, client.profileKeys.public, packet.crypto.signature)
}
}
if (client.supportFeature('signedChat')) chatPlugin(client, server, options)
server.emit('login', client)
}
}

View file

@ -1,6 +1,6 @@
'use strict'
module.exports = {
defaultVersion: '1.19',
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']
}

View file

@ -2,20 +2,6 @@ module.exports = client => {
const mcData = require('minecraft-data')(client.version)
const hasSignedChat = mcData.supportFeature('signedChat')
client.chat = (message) => {
if (hasSignedChat) {
const timestamp = BigInt(Date.now())
client.write('chat_message', {
message,
timestamp,
salt: 0,
signature: Buffer.alloc(0)
})
} else {
client.write('chat', { message })
}
}
client.nextMessage = (containing) => {
return new Promise((resolve) => {
function onChat (packet) {