refactor into nice npm module. closes #7

This commit is contained in:
Andrew Kelley 2013-01-01 23:39:31 -05:00
parent dc2aa5222c
commit c405ac21f8
5 changed files with 246 additions and 290 deletions

View File

@ -20,6 +20,26 @@ Parse and serialize minecraft packets, plus authentication and encryption.
Supports Minecraft version 1.4.6 Supports Minecraft version 1.4.6
## Try it out so far ## Usage
`MC_EMAIL=you@example.com MC_PASSWORD=your_pass node test.js` ### Echo example
Listen for chat messages and echo them back.
```js
var mc = require('minecraft-protocol');
var client = mc.createClient({
host: "localhost", // optional
port: 25565, // optional
username: "player",
email: "email@example.com", // email and password are required only for
password: "12345678", // encrypted and online servers
});
client.on('packet', function(packet) {
if (packet.id !== 0x03) return;
if (packet.message.indexOf(client.session.username) !== -1) return;
client.writePacket(0x03, {
message: packet.message,
});
});
```

View File

@ -3,31 +3,149 @@ var net = require('net')
, util = require('util') , util = require('util')
, assert = require('assert') , assert = require('assert')
, Iconv = require('iconv').Iconv , Iconv = require('iconv').Iconv
, packets = require('../packets.json') , ursa = require('ursa')
, crypto = require('crypto')
, superagent = require('superagent')
, Batch = require('batch')
, packets = require('./packets.json')
, toUcs2 = new Iconv('UTF-8', 'utf16be') , toUcs2 = new Iconv('UTF-8', 'utf16be')
, fromUcs2 = new Iconv('utf16be', 'UTF-8') , fromUcs2 = new Iconv('utf16be', 'UTF-8')
require('buffer-more-ints'); require('buffer-more-ints');
module.exports = Parser; exports.createClient = createClient;
function Parser(options) { function createClient(options) {
// defaults
options = options || {};
var port = options.port || 25565;
var host = options.host || 'localhost';
assert.ok(options.username, "username is required");
var haveCredentials = options.email && options.password;
var packetHandlers = {
0x00: onKeepAlive,
0xFC: onEncryptionKeyResponse,
0xFD: onEncryptionKeyRequest,
};
var client = new Client();
client.on('connect', function() {
client.writePacket(0x02, {
protocolVersion: packets.meta.protocolVersion,
username: options.username,
serverHost: host,
serverPort: port,
});
});
client.on('packet', function(packet) {
var handler = packetHandlers[packet.id];
if (handler) handler(packet);
});
client.connect(port, host);
return client;
function onKeepAlive(packet) {
client.writePacket(0x00, {
keepAliveId: packet.keepAliveId
});
}
function onEncryptionKeyRequest(packet) {
if (! haveCredentials) {
var err = new Error("server is in online mode and no credentials supplied");
err.code = 'ENOCRED';
client.emit('error', err);
client.end();
return;
}
var hash = crypto.createHash('sha1');
hash.update(packet.serverId);
var batch = new Batch();
batch.push(function(cb) { getLoginSession(options.email, options.password, cb); });
batch.push(function(cb) { crypto.randomBytes(16, cb); });
batch.end(function (err, results) {
if (err) {
client.emit('error', err);
client.end();
return
}
client.session = results[0];
client.emit('session');
var sharedSecret = results[1];
hash.update(sharedSecret);
hash.update(packet.publicKey);
var digest = mcHexDigest(hash);
var request = superagent.get("http://session.minecraft.net/game/joinserver.jsp");
request.query({
user: client.session.username,
sessionId: client.session.id,
serverId: digest,
});
request.end(function(err, resp) {
var myErr;
if (err) {
client.emit('error', err);
client.end();
} else if (resp.serverError) {
myErr = new Error("session.minecraft.net is broken: " + resp.status);
myErr.code = 'EMCSESSION500';
client.emit('error', myErr);
client.end();
} else if (resp.clientError) {
myErr = new Error("session.minecraft.net rejected request: " + resp.status + " " + resp.text);
myErr.code = 'EMCSESSION400';
client.emit('error', myErr);
client.end();
} else {
sendEncryptionKeyResponse();
}
});
function sendEncryptionKeyResponse() {
var pubKey = mcPubKeyToURsa(packet.publicKey);
var encryptedSharedSecret = pubKey.encrypt(sharedSecret, 'binary', 'base64', ursa.RSA_PKCS1_PADDING);
var encryptedSharedSecretBuffer = new Buffer(encryptedSharedSecret, 'base64');
var encryptedVerifyToken = pubKey.encrypt(packet.verifyToken, 'binary', 'base64', ursa.RSA_PKCS1_PADDING);
var encryptedVerifyTokenBuffer = new Buffer(encryptedVerifyToken, 'base64');
client.cipher = crypto.createCipheriv('aes-128-cfb8', sharedSecret, sharedSecret);
client.decipher = crypto.createDecipheriv('aes-128-cfb8', sharedSecret, sharedSecret);
client.writePacket(0xfc, {
sharedSecret: encryptedSharedSecretBuffer,
verifyToken: encryptedVerifyTokenBuffer,
});
}
});
}
function onEncryptionKeyResponse(packet) {
assert.strictEqual(packet.sharedSecret.length, 0);
assert.strictEqual(packet.verifyToken.length, 0);
client.encryptionEnabled = true;
client.writePacket(0xcd, { payload: 0 });
}
}
function Client(options) {
EventEmitter.call(this); EventEmitter.call(this);
this.client = null; this.socket = null;
this.encryptionEnabled = false; this.encryptionEnabled = false;
this.cipher = null; this.cipher = null;
this.decipher = null; this.decipher = null;
} }
util.inherits(Parser, EventEmitter); util.inherits(Client, EventEmitter);
Parser.prototype.connect = function(port, host) { Client.prototype.connect = function(port, host) {
var self = this; var self = this;
self.client = net.connect(port, host, function() { self.socket = net.connect(port, host, function() {
self.emit('connect'); self.emit('connect');
}); });
var incomingBuffer = new Buffer(0); var incomingBuffer = new Buffer(0);
self.client.on('data', function(data) { self.socket.on('data', function(data) {
if (self.encryptionEnabled) data = new Buffer(self.decipher.update(data), 'binary'); if (self.encryptionEnabled) data = new Buffer(self.decipher.update(data), 'binary');
incomingBuffer = Buffer.concat([incomingBuffer, data]); incomingBuffer = Buffer.concat([incomingBuffer, data]);
var parsed; var parsed;
@ -39,24 +157,23 @@ Parser.prototype.connect = function(port, host) {
} }
}); });
self.client.on('error', function(err) { self.socket.on('error', function(err) {
self.emit('error', err); self.emit('error', err);
}); });
self.client.on('end', function() { self.socket.on('close', function() {
self.emit('end'); self.emit('end');
}); });
}; };
Parser.prototype.end = function() { Client.prototype.end = function() {
this.client.end(); this.socket.end();
}; };
Parser.prototype.writePacket = function(packetId, params) { Client.prototype.writePacket = function(packetId, params) {
var buffer = createPacketBuffer(packetId, params); var buffer = createPacketBuffer(packetId, params);
var out = this.encryptionEnabled ? new Buffer(this.cipher.update(buffer), 'binary') : buffer; var out = this.encryptionEnabled ? new Buffer(this.cipher.update(buffer), 'binary') : buffer;
if (this.encryptionEnabled) console.log("writing", packetId, "packet with encryption"); this.socket.write(out);
this.client.write(out);
}; };
var writers = { var writers = {
@ -598,7 +715,6 @@ function createPacketBuffer(packetId, params) {
function parsePacket(buffer) { function parsePacket(buffer) {
if (buffer.length < 1) return null; if (buffer.length < 1) return null;
var packetId = buffer.readUInt8(0); var packetId = buffer.readUInt8(0);
console.log("parsing packet " + packetId);
var size = 1; var size = 1;
var results = { id: packetId }; var results = { id: packetId };
var packetInfo = packets[packetId]; var packetInfo = packets[packetId];
@ -623,78 +739,80 @@ function parsePacket(buffer) {
}; };
} }
// packet ids function mcPubKeyToURsa(mcPubKeyBuffer) {
Parser.KEEP_ALIVE = 0x00; var pem = "-----BEGIN PUBLIC KEY-----\n";
Parser.LOGIN_REQUEST = 0x01; var base64PubKey = mcPubKeyBuffer.toString('base64');
Parser.HANDSHAKE = 0x02; var maxLineLength = 65;
Parser.CHAT_MESSAGE = 0x03; while (base64PubKey.length > 0) {
Parser.TIME_UPDATE = 0x04; pem += base64PubKey.substring(0, maxLineLength) + "\n";
Parser.ENTITY_EQUIPMENT = 0x05; base64PubKey = base64PubKey.substring(maxLineLength);
Parser.SPAWN_POSITION = 0x06; }
Parser.USE_ENTITY = 0x07; pem += "-----END PUBLIC KEY-----\n";
Parser.UPDATE_HEALTH = 0x08; return ursa.createPublicKey(pem, 'utf8');
Parser.RESPAWN = 0x09; }
Parser.PLAYER = 0x0A;
Parser.PLAYER_POSITION = 0x0B; function mcHexDigest(hash) {
Parser.PLAYER_LOOK = 0x0C; var buffer = new Buffer(hash.digest(), 'binary');
Parser.PLAYER_POSITION_AND_LOOK = 0x0D; // check for negative hashes
Parser.PLAYER_DIGGING = 0x0E; var negative = buffer.readInt8(0) < 0;
Parser.PLAYER_BLOCK_PLACEMENT = 0x0F; if (negative) performTwosCompliment(buffer);
Parser.HELD_ITEM_CHANGE = 0x10; var digest = buffer.toString('hex');
Parser.USE_BED = 0x11; // trim leading zeroes
Parser.ANIMATION = 0x12; digest = digest.replace(/^0+/g, '');
Parser.ENTITY_ACTION = 0x13; if (negative) digest = '-' + digest;
Parser.SPAWN_NAMED_ENTITY = 0x14; return digest;
Parser.COLLECT_ITEM = 0x16;
Parser.SPAWN_OBJECT_VEHICLE = 0x17; function performTwosCompliment(buffer) {
Parser.SPAWN_MOB = 0x18; var carry = true;
Parser.SPAWN_PAINTING = 0x19; var i, newByte, value;
Parser.SPAWN_EXPERIENCE_ORB = 0x1A; for (i = buffer.length - 1; i >= 0; --i) {
Parser.ENTITY_VELOCITY = 0x1C; value = buffer.readUInt8(i);
Parser.DESTROY_ENTITY = 0x1D; newByte = ~value & 0xff;
Parser.ENTITY = 0x1E; if (carry) {
Parser.ENTITY_RELATIVE_MOVE = 0x1F; carry = newByte === 0xff;
Parser.ENTITY_LOOK = 0x20; buffer.writeUInt8(newByte + 1, i);
Parser.ENTITY_LOOK_AND_RELATIVE_MOVE = 0x21; } else {
Parser.ENTITY_TELEPORT = 0x22; buffer.writeUInt8(newByte, i);
Parser.ENTITY_HEAD_LOOK = 0x23; }
Parser.ENTITY_STATUS = 0x26; }
Parser.ATTACH_ENTITY = 0x27; }
Parser.ENTITY_METADATA = 0x28; }
Parser.ENTITY_EFFECT = 0x29;
Parser.REMOVE_ENTITY_EFFECT = 0x2A; function getLoginSession(email, password, cb) {
Parser.SET_EXPERIENCE = 0x2B; var req = superagent.post("https://login.minecraft.net");
Parser.CHUNK_DATA = 0x33; req.type('form');
Parser.MULTI_BLOCK_CHANGE = 0x34; req.send({
Parser.BLOCK_CHANGE = 0x35; user: email,
Parser.BLOCK_ACTION = 0x36; password: password,
Parser.BLOCK_BREAK_ANIMATION = 0x37; version: packets.meta.sessionVersion,
Parser.MAP_CHUNK_BULK = 0x38; });
Parser.EXPLOSION = 0x3C; req.end(function(err, resp) {
Parser.SOUND_OR_PARTICLE_EFFECT = 0x3D; var myErr;
Parser.NAMED_SOUND_EFFECT = 0x3E; if (err) {
Parser.CHANGE_GAME_STATE = 0x46; cb(err);
Parser.SPAWN_GLOBAL_ENTITY = 0x47; } else if (resp.serverError) {
Parser.OPEN_WINDOW = 0x64; myErr = new Error("login.minecraft.net is broken: " + resp.status);
Parser.CLOSE_WINDOW = 0x65; myErr.code = 'ELOGIN500';
Parser.CLICK_WINDOW = 0x66; cb(myErr);
Parser.SET_SLOT = 0x67; } else if (resp.clientError) {
Parser.SET_WINDOW_ITEMS = 0x68; myErr = new Error("login.minecraft.net rejected request: " + resp.status + " " + resp.text);
Parser.UPDATE_WINDOW_PROPERTY = 0x69; myErr.code = 'ELOGIN400';
Parser.CONFIRM_TRANSACTION = 0x6A; cb(myErr);
Parser.CREATIVE_INVENTORY_ACTION = 0x6B; } else {
Parser.ENCHANT_ITEM = 0x6C; var values = resp.text.split(':');
Parser.UPDATE_SIGN = 0x82; var session = {
Parser.ITEM_DATA = 0x83; currentGameVersion: values[0],
Parser.UPDATE_TILE_ENTITY = 0x84; username: values[2],
Parser.INCREMENT_STATISTIC = 0xC8; id: values[3],
Parser.PLAYER_LIST_ITEM = 0xC9; uid: values[4],
Parser.PLAYER_ABILITIES = 0xCA; };
Parser.TAB_COMPLETE = 0xCB; if (session.id && session.username) {
Parser.CLIENT_SETTINGS = 0xCC; cb(null, session);
Parser.CLIENT_STATUSES = 0xCD; } else {
Parser.PLUGIN_MESSAGE = 0xFA; myErr = new Error("login.minecraft.net rejected request: " + resp.status + " " + resp.text);
Parser.ENCRYPTION_KEY_RESPONSE = 0xFC; myErr.code = 'ELOGIN400';
Parser.ENCRYPTION_KEY_REQUEST = 0xFD; cb(myErr);
Parser.SERVER_LIST_PING = 0xFE; }
Parser.DISCONNECT_KICK = 0xFF; }
});
}

View File

@ -32,6 +32,7 @@
"ursa": "~0.8.0", "ursa": "~0.8.0",
"buffer-more-ints": "~0.0.1", "buffer-more-ints": "~0.0.1",
"superagent": "~0.10.0", "superagent": "~0.10.0",
"iconv": "~1.2.4" "iconv": "~1.2.4",
"batch": "~0.2.1"
} }
} }

View File

@ -1,4 +1,8 @@
{ {
"meta": {
"protocolVersion": 51,
"sessionVersion": 13
},
"0": [ "0": [
{ {
"name": "keepAliveId", "name": "keepAliveId",

201
test.js
View File

@ -1,200 +1,13 @@
var Parser = require('./lib/parser') var mc = require('./');
, ursa = require('ursa') var client = mc.createClient({
, crypto = require('crypto') username: process.env.MC_USERNAME,
, assert = require('assert')
, superagent = require('superagent')
var input = {
email: process.env.MC_EMAIL, email: process.env.MC_EMAIL,
password: process.env.MC_PASSWORD, password: process.env.MC_PASSWORD,
serverHost: 'localhost',
serverPort: 25565,
};
var parser = new Parser();
var loginSession = null;
parser.on('connect', function() {
console.info("connect");
parser.writePacket(Parser.HANDSHAKE, {
protocolVersion: 51,
username: loginSession.username,
serverHost: input.serverHost,
serverPort: input.serverPort,
}); });
}); client.on('packet', function(packet) {
parser.on('packet', function(packet) { if (packet.id !== 0x03) return;
var handler = packetHandlers[packet.id]; if (packet.message.indexOf(client.session.username) !== -1) return;
if (handler) { client.writePacket(0x03, {
handler(packet);
} else {
console.warn("No packet handler for", packet.id, "fields", packet);
}
});
parser.on('error', function(err) {
console.error("error connecting", err.stack);
});
parser.on('end', function() {
console.info("disconnect");
});
getLoginSession(function() {
parser.connect(input.serverPort, input.serverHost);
});
function getLoginSession(cb) {
console.log("logging in to minecraft.net");
var req = superagent.post("https://login.minecraft.net");
req.type('form');
req.send({
user: input.email,
password: input.password,
version: 13,
});
req.end(function(err, resp) {
if (err) {
cb(err);
} else if (! resp.ok) {
cb(new Error("login.minecraft.net status " + resp.status + ": " + resp.text));
} else {
var values = resp.text.split(':');
var session = {
currentGameVersion: values[0],
username: values[2],
id: values[3],
uid: values[4],
};
if (session.id && session.username) {
loginSession = session;
console.info("logged in as", session.username);
cb();
} else {
cb(new Error("login.minecraft.net says " + session.currentGameVersion));
}
}
});
}
var packetHandlers = {
0x00: onKeepAlive,
0x01: onLoginRequest,
0x03: onChatMessage,
0xFC: onEncryptionKeyResponse,
0xFD: onEncryptionKeyRequest,
0xFF: onKick,
};
function onKeepAlive(packet) {
parser.writePacket(Parser.KEEP_ALIVE, {
keepAliveId: packet.keepAliveId
});
}
function onKick(packet) {
console.log("kick", packet);
}
function onLoginRequest(packet) {
console.log("login request", packet);
}
function onChatMessage(packet) {
console.log("chat message", packet);
if (packet.message.indexOf(loginSession.username) === -1) {
parser.writePacket(Parser.CHAT_MESSAGE, {
message: packet.message, message: packet.message,
}); });
}
}
function onEncryptionKeyRequest(packet) {
console.log("enc key request");
var hash = crypto.createHash('sha1');
hash.update(packet.serverId);
crypto.randomBytes(16, function (err, sharedSecret) {
assert.ifError(err);
hash.update(sharedSecret);
hash.update(packet.publicKey);
var digest = mcHexDigest(hash);
var request = superagent.get("http://session.minecraft.net/game/joinserver.jsp");
request.query({
user: loginSession.username,
sessionId: loginSession.id,
serverId: digest,
}); });
request.end(function(err, resp) {
if (err) {
console.error("session.minecraft.net not available");
// TODO emit error
} else if (! resp.ok) {
console.error("session.minecraft.net returned error:", resp.status, resp.text);
// TODO emit error
} else {
sendEncryptionKeyResponse();
}
});
function sendEncryptionKeyResponse() {
var pubKey = mcPubKeyToURsa(packet.publicKey);
var encryptedSharedSecret = pubKey.encrypt(sharedSecret, 'binary', 'base64', ursa.RSA_PKCS1_PADDING);
var encryptedSharedSecretBuffer = new Buffer(encryptedSharedSecret, 'base64');
var encryptedVerifyToken = pubKey.encrypt(packet.verifyToken, 'binary', 'base64', ursa.RSA_PKCS1_PADDING);
var encryptedVerifyTokenBuffer = new Buffer(encryptedVerifyToken, 'base64');
parser.cipher = crypto.createCipheriv('aes-128-cfb8', sharedSecret, sharedSecret);
parser.decipher = crypto.createDecipheriv('aes-128-cfb8', sharedSecret, sharedSecret);
console.log("write enc key response");
parser.writePacket(Parser.ENCRYPTION_KEY_RESPONSE, {
sharedSecret: encryptedSharedSecretBuffer,
verifyToken: encryptedVerifyTokenBuffer,
});
}
});
}
function onEncryptionKeyResponse(packet) {
console.log("confirmation enc key response");
assert.strictEqual(packet.sharedSecret.length, 0);
assert.strictEqual(packet.verifyToken.length, 0);
parser.encryptionEnabled = true;
parser.writePacket(Parser.CLIENT_STATUSES, { payload: 0 });
}
function mcPubKeyToURsa(mcPubKeyBuffer) {
var pem = "-----BEGIN PUBLIC KEY-----\n";
var base64PubKey = mcPubKeyBuffer.toString('base64');
var maxLineLength = 65;
while (base64PubKey.length > 0) {
pem += base64PubKey.substring(0, maxLineLength) + "\n";
base64PubKey = base64PubKey.substring(maxLineLength);
}
pem += "-----END PUBLIC KEY-----\n";
return ursa.createPublicKey(pem, 'utf8');
}
function mcHexDigest(hash) {
var buffer = new Buffer(hash.digest(), 'binary');
// check for negative hashes
var negative = buffer.readInt8(0) < 0;
if (negative) performTwosCompliment(buffer);
var digest = buffer.toString('hex');
// trim leading zeroes
digest = digest.replace(/^0+/g, '');
if (negative) digest = '-' + digest;
return digest;
function performTwosCompliment(buffer) {
var carry = true;
var i, newByte, value;
for (i = buffer.length - 1; i >= 0; --i) {
value = buffer.readUInt8(i);
newByte = ~value & 0xff;
if (carry) {
carry = newByte === 0xff;
buffer.writeUInt8(newByte + 1, i);
} else {
buffer.writeUInt8(newByte, i);
}
}
}
}