mc的通信协议及其实现

简介

本文是本人基于对wiki.vg的一些理解,以及从他人学到的代码来实现与mc服务器间通信协议的理解及实现。
同时包含了开发mCClient时遇到的一些问题,关于mCClient可以看前面的文章。
实现时使用Java,目前仅仅实现了使用UUID和AccessToken进行正版验证,本文不包含使用微软验证获取UUID和AccessToken。
本文具有一定的时效性,仅针对757协议版本(MC1.18)。

文章仅个人理解,不排除有一些错误的理解(虽然大概能实现对应的功能),欢迎大家多多指教。

提示:为方便阅读,可点击进入文章,使用左侧的书签。

正文

一、总览

通信的流程

由外层向内层来看:加密/解密 > 压缩/解压 > 数据包 > 数据。
和计算机网络的分层差不多一个意思,几层东西各自逐步实现,互不干预。

关键点:加密/解密的实现、数据包的压缩与解压、数据包的格式、数据类型的实现。
搞懂了这四点,这个方面基本上就没问题了(如果只为离线模式准备的,不需要第一点)。

建立连接

按照wiki上的就行,

  1. 客户端连接到服务器
  2. C → S:握手状态=2
  3. C → S:登录开始
  4. S → C:加密请求
  5. 客户端认证
  6. C → S:加密响应
  7. 服务器身份验证,都启用加密
  8. S → C:设置压缩(可选,启用压缩)
  9. S → C:登录成功

ps:离线模式不需要4,5,6。

建立连接后,每过一定时间服务器会向你发送保活数据包。收到之后需要及时应答,不然就给你算超时掉线了。

关键点:接收/发送数据包。

正版验证

关于使用微软验证我还没有搞,所以现在假设已经从微软验证那里搞到了你的UUID和AccessToken。

其实只需要把UUID、AccessToken和你要登录的服务器的ID,post到官方的网站。
等到服务器决定让不让你进来时,服务器再去官方的网站查查你是不是要进来。
如果上面post成功了(里面的东西没问题),服务器就会让你加入游戏,反之则加入失败。

关键点:请求格式、服务器ID的获取。

二、具体细节

数据格式

目前还没用到多少数据格式,其实总共应该也没多少。
只说几个特殊的,剩下的直接用DataInputStream/DataOutputStream里面的方法就行。

String

格式:UTF-8
在他前面会有一个VarInt标记字符串的大小。
后面跟上字符串的byte[]就行了。

UUID

long mostSig = readLong();
long leastSig = readLong();
return new UUID(mostSig, leastSig);

VarInt和VarLong

这个直接看wiki就行,上面已经给出了伪代码。

数据包的格式

有两种数据包:1、无压缩的数据包,2、带压缩的数据包
下面先说两者都有的元素:数据包长度,数据包ID,数据。最后再说两者具体的格式。

数据包长度

无论你是否启用压缩、是否真正压缩,数据包前面都会加上此数据包的长度,是数据包最前面的 一个VarInt。表明当前数据包(不包括数据包长度本身)的大小。

数据包ID

紧贴在数据前面的 一个VarInt,用来区分不同功能的包。发送时ID比数据早发送。

注意:不同的包在不同的游戏状态下的ID可能一样的,需要先确定当前的游戏模式。
例如:“登录成功”的数据包会将连接状态切换为play。这时对数据包ID的判断就需要变一下了,不能还按Login状态的来。

数据

按照wiki上所给格式,使用上面所说的各种数据类型,从上到下依次写入/读取即可。最后生成一个byte[]作为数据。

无压缩的数据包

还未启用压缩时(建立连接的第8步之前)或不启用压缩时,使用的数据包。

格式:数据包长度 + 数据包ID + 数据

数据包长度 = 数据包ID的长度 + 数据的长度

带压缩的数据包

一旦服务器向你发送了Set Compression数据包(建立连接的第8步)。(你和服务器)后续的数据包都会启用zlib压缩(只压缩ID和数据),采用“带压缩的数据包”的格式(无论其是否真正发生了压缩)。

格式:数据包长度 + [ 没压缩时的( ID及数据 )] 的长度 + (数据包ID + 数据)的压缩包 或 (数据包ID + 数据)

数据包长度

数据包长度 = { 没压缩时的ID及数据的长度 } 的长度 + [ 压缩后的 ( ID及数据 ) ] 的长度

没压缩时的ID及数据的长度

一个VarInt。表明(ID及数据)压缩前/解压后的长度,及上图的“压缩包原长”

注意:在(ID及数据)的长度小于阈值(由Set Compression得到)的时候“没压缩时的ID及数据的长度”为0,且数据包第三部分为“数据包ID + 数据”,不会被压缩。就是上图黄线所表示的那样。

ID及数据的压缩包 或 ID及数据

有关使用哪个 的判断方法,上面那个“注意”已经提到了。

关于压缩/解压缩下面会说。

数据包的压缩/解压缩

其实主要是解压就行了,解压从服务器发过来的压缩包。给服务器发的包可以偷个懒,直接不压缩(大部分情况下你的数据不会超过阈值)。

解压缩

使用“Inflater”这个类来解压缩。

private final Inflater inflater = new Inflater();

byte[] zip = new byte[len - VarOutputStream.checkVarIntSize(dataLen)];
packetBuf.readFully(zip);
byte[] unzip = new byte[dataLen];
inflater.setInput(zip);
inflater.inflate(unzip);
inflater.reset();
packetBuf = new VarInputStream(new ByteArrayInputStream(unzip));
id = packetBuf.readVarInt();
packetData = new byte[dataLen - 1];
packetBuf.readFully(packetData);

解释:

  • len:数据包长度
  • dataLen:数据长度
  • dataLen - 1 里的“1”:ID的长度
  • checkVarIntSize(<VarInt>):返回Varint的长度

主要就是中间那三行Inflater类的方法。

压缩

使用“Deflater”这个类就能压缩。用处不大,就没有写了。

加密/解密(离线模式不需要)

首先得搞清楚设置服务器与你设置加密的流程。

设置加密的流程

  1. S → C:服务器ID、服务器公钥、验证令牌
  2. C:生成一个共享密钥
  3. C → S:使用服务器公钥加密的 共享密钥和验证令牌

此时你和服务器都已经(秘密地)知道了共享密钥,后面的通信都会使用共享密钥进行加密。

为什么能“秘密地”?(选读)

第一次接触这种东西,感觉好神奇。下面的只是我自己的理解,肯定不太严谨。

服务器会生成一对钥匙,一个公钥,一个密钥。公钥是谁都可以知道的,密匙只有服务器自己知道。由公钥加密的内容,只能用密匙解密。

任何人往服务器发东西,都得先使用公钥加密。这样一来,只有拥有密钥的服务器可获取你发送的内容。现在就实现了你到服务器单向的保密。

客户端会生成一个共享密匙,用服务器公钥加密发给服务器。现在只有你和服务器知道这个共享密匙,你们就可以使用这个共享密匙给对话加密、解密了。

加密流程的对应实现

  1. 获取到服务器的加密请求后,读取服务器编号、服务器公钥、验证令牌。(服务器编号是给正版验证使用的,下面再说)
  2. 使用项目里Tool类的方法获取共享密匙。
    public static SecretKey generateSecretKey() {
        try {
            KeyGenerator keygenerator = KeyGenerator.getInstance("AES");
            keygenerator.init(128);
            return keygenerator.generateKey();
        } catch (NoSuchAlgorithmException e) {
            e.printStackTrace();
        }
        return null;
    }
  3. 按照协议给把共享密钥和验证令牌装到包里再用公钥加密返给服务器。
    public static PublicKey byteToPublicKey(byte[] p_13601_) {
        EncodedKeySpec encodedkeyspec = new X509EncodedKeySpec(p_13601_);
        try {
            KeyFactory keyfactory = KeyFactory.getInstance("RSA");
            return keyfactory.generatePublic(encodedkeyspec);
        } catch (InvalidKeySpecException | NoSuchAlgorithmException e) {
            e.printStackTrace();
        }
        return null;
    }
    //看到“p_13601_”这个参数名,读过mc源码的同学应该能想到了,Tool类就来自mc的源码。虽然那里不叫这名。

剩下的加密/解密使用Cipher类就差不多了,这里就不多介绍了,项目里的代码应该能看懂。

接收/发送数据包

可以参照总览里的流程:

  • 接收服务器发送的数据:解密 > 解压 > 根据包的ID进行对应的分析 > 获取数据

  • 往服务器发送数据:设置数据 > 加上包的ID > 压缩 > 加密

剩下的东西就是Java里I/O流的事情了。

正版验证

这个我只搞了通过uuid,token进行验证。

在建立连接的第5步,向官方的验证网站发送post请求。

https://sessionserver.mojang.com/session/minecraft/join

内容是一串json:
{
“accessToken” : “<accessToken>” ,
“selectedProfile” : “<player’s uuid without dashes>” ,
“serverId” : “<serverHash>”
}
post要加上Content-Type: application/json这个参数。

serverId的获取

String serverID = (new BigInteger(Tool.digestData(serverId, publickey, secretkey))).toString(16);

public static byte[] digestData(String s, PublicKey publicKey, SecretKey secretKey) throws Exception {
    return digestData(s.getBytes("ISO_8859_1"), secretKey.getEncoded(), publicKey.getEncoded());
}

private static byte[] digestData(byte[]... bytes) throws Exception {
    MessageDigest messagedigest = MessageDigest.getInstance("SHA-1");

    for (byte[] aByte : bytes) {
        messagedigest.update(aByte);
    }
    return messagedigest.digest();
}

serverId(方法第一个参数)是来自加密请求那里的服务器ID,publickey和secretkey上面已经说过了。

结语

写到这里,东西介绍的应该差不多了,希望能帮助到你。

感谢

感谢下面这些资料对我的帮助:




性感CC - 在线找打
------ 我是分割线 ------