mirror of
https://github.com/PrismarineJS/node-minecraft-protocol.git
synced 2024-11-14 10:55:05 -05:00
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:
parent
1efbde1ef7
commit
367c01567c
16 changed files with 615 additions and 95 deletions
23
docs/API.md
23
docs/API.md
|
@ -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
54
docs/chat.md
Normal 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).
|
|
@ -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())
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
@ -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
287
src/client/chat.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}],
|
||||
|
|
|
@ -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
14
src/index.d.ts
vendored
|
@ -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
166
src/server/chat.js
Normal 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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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']
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
Loading…
Reference in a new issue