Home

Awesome

Project KuimiVM

你好,这里是 Kilio Kuara。

由于各方面的原因,我们最终还是将此项目开源出来了。 我们知道此项目今后可能已经没有意义了,但我们依旧希望在 QQ Bot 的历史中留下属于我们的一笔。

抛开 QQ Bot 不谈,此项目所实现的核心逻辑也是值得开源的。所以,我们选择公开此技术。

<div align="right"> <i>

乐章的第二篇已经落幕,乐章的第三篇又在何处

</i> </div>

Structure


magic-signer-guide

此项目用于解决各种 QQ 机器人框架的 sso sign 和 tlv 加密问题。

该项目为 RPC 服务后端,并提供 HTTP API,这意味着你可以根据框架的需求实现不同 RPC 客户端。

由于该项目扮演的角色比较特殊,为了保证客户端与服务端的通信安全,需要先进行认证,才可执行业务操作。

本项目(docker 镜像 kiliokuara/vivo50,以下相同)在可控范围内<sup>(1)</sup>不会持久化 QQ 机器人的以下信息:

为优化业务逻辑,本项目会持久化 QQ 机器人的以下信息:

强烈建议自行部署 RPC 服务端,避免使用他人部署的开放的 RPC 服务端。

(1) 可控范围指 RPC 服务端的所有代码。由于项目使用到了外置库 libfekit,我们无法得知 libfekit 会持久化的信息。

支持的 QQ 版本

使用方法

通过 docker 部署 RPC 服务端

$ docker pull kiliokuara/vivo50:latest
$ docker run -d --restart=always \
  -e SERVER_IDENTITY_KEY=vivo50 \
  -e AUTH_KEY=kfc \
  -e PORT=8888 \
  -p 8888:8888 \
  --log-opt mode=non-blocking --log-opt max-buffer-size=4m \
  -v /home/vivo50/serverData:/app/serverData \
  -v /home/vivo50/testbot:/app/testbot \
  --name vivo50 \
  --memory 200M \
  kiliokuara/vivo50

环境变量说明:

更多详情请参考 https://docs.docker.com/engine/reference/commandline/run/

内存使用参考

登录机器人前

2023-07-27 16:29:46 [DEBUG] [Vivo45#1] MemoryDumper -                  committed |  init   |  used   |   max  
2023-07-27 16:29:46 [DEBUG] [Vivo45#1] MemoryDumper -     Heap Memory:  200.0MB  | 200.0MB | 18.47MB | 200.0MB
2023-07-27 16:29:46 [DEBUG] [Vivo45#1] MemoryDumper - Non-Heap Memory:  25.25MB  | 7.31MB  | 23.27MB |   -1   
2023-07-27 16:29:51 [DEBUG] [Vivo45#2] MemoryDumper -                  committed |  init   |  used   |   max  
2023-07-27 16:29:51 [DEBUG] [Vivo45#2] MemoryDumper -     Heap Memory:  44.0MB   | 200.0MB | 12.08MB | 200.0MB
2023-07-27 16:29:51 [DEBUG] [Vivo45#2] MemoryDumper - Non-Heap Memory:  25.25MB  | 7.31MB  | 22.17MB |   -1   
2023-07-27 16:29:56 [DEBUG] [Vivo45#1] MemoryDumper -                  committed |  init   |  used   |   max  
2023-07-27 16:29:56 [DEBUG] [Vivo45#1] MemoryDumper -     Heap Memory:  44.0MB   | 200.0MB | 11.08MB | 200.0MB
2023-07-27 16:29:56 [DEBUG] [Vivo45#1] MemoryDumper - Non-Heap Memory:  25.25MB  | 7.31MB  | 21.62MB |   -1   


登录一个机器人后

2023-07-27 16:30:41 [DEBUG] [Vivo45#3] MemoryDumper -                  committed |  init   |  used   |   max  
2023-07-27 16:30:41 [DEBUG] [Vivo45#3] MemoryDumper -     Heap Memory:  52.0MB   | 200.0MB | 33.13MB | 200.0MB
2023-07-27 16:30:41 [DEBUG] [Vivo45#3] MemoryDumper - Non-Heap Memory:  44.56MB  | 7.31MB  | 41.21MB |   -1   
2023-07-27 16:30:46 [DEBUG] [Vivo45#3] MemoryDumper -                  committed |  init   |  used   |   max  
2023-07-27 16:30:46 [DEBUG] [Vivo45#3] MemoryDumper -     Heap Memory:  52.0MB   | 200.0MB | 28.15MB | 200.0MB
2023-07-27 16:30:46 [DEBUG] [Vivo45#3] MemoryDumper - Non-Heap Memory:  44.56MB  | 7.31MB  | 40.68MB |   -1

认证流程

1. 获取 RPC 服务端信息,并验证服务端的身份

首先调用 API GET /service/rpc/handshake/config,获取回应如下:

{
  "publicKey": "", // RSA 公钥,用于 客户端验证服务端身份 和下一步的 加密握手信息。 
  "timeout": 10000, // 会话过期时间(单位:毫秒)
  "keySignature": "" // 服务端公钥签名,用于 客户端验证服务端身份
}

为了防止 MITM Attack(中间人攻击),客户端需要验证服务端的身份。通过如下计算:

$clientKeySignature = $sha1(
    $sha1( ($SERVER_IDENTITY_KEY + $publicKey).getBytes() ).hex() + $SERVER_IDENTITY_KEY
).hex()

clientKeySignature 与 API 返回的 keySignature 比对即可验证服务端身份。

以 Kotlin 为例:


fun ByteArray.sha1(): ByteArray {
    return MessageDigest.getInstance("SHA1").digest(this)
}
fun ByteArray.hex(): String {
    return HexFormat.of().withLowerCase().formatHex(this)
}

val serverIdentityKey: String = ""; // 服务端 SERVER_IDENTITY_KEY
val publicKey: String = ""; // API 返回的 publicKey 字符串,该字符串是 base64 编码的 RSA 公钥。
val serverKeySignature: String = ""; // API 返回的 keySignature 字符串,该字符串是服务端计算签名。

val pKeyRsaSha1 = (serverIdentityKey + publicKey).toByteArray().sha1()
val clientKeySignature = (pKeyRsaSha1.hex() + serverIdentityKey).toByteArray().sha1().hex()

if (!clientKeySignature.equals(serverKeySignature)) {
    throw IllegalStateException("client calculated key signature doesn't match the server provides.")
}

2. 与服务端握手

在与服务端握手之前,需要客户端生成一个 16-byte AES 密钥和 4096-bit RSA 密钥对。

生成密钥后,调用 API POST /service/rpc/handshake/handshake,请求体如下:

{
  "clientRsa": "", // 客户端生成的 RSA 密钥对中的公钥,使用 base64 编码。
  "secret": "....", // 握手信息,使用上一步 “获取 RPC 服务端信息” 中的 publicKey,采用 RSA/ECB/PKCS1Padding 套件加密。
}

// 握手信息如下
{
  "authorizationKey": "", // 服务端的 AUTH_KEY
  "sharedKey": "", // AES 密钥
  "botid": 1234567890, // Bot QQ 号
}

回应如下:

{
  "status": 200, // 200 = 握手成功,403 = 握手失败
  "reason": "Authorization code is invalid.", // 握手失败的原因,仅握手失败会有此属性。
  "token": "", // WebSocket 通信 token,使用 base64 编码,仅握手成功会有此属性。
}

token 进行 base64 解码,至此握手过程已结束,接下来进行 WebSocket 通信。

3. 开启 WebSocket 会话

访问 API WEBSOCKET /service/rpc/session,请求需要添加以下 headers:

Authorization: $token_decoded     <--- base64 解码后的 token
X-SEC-Time: $timestamp_millis     <--- 当前时间戳,毫秒
X-SEC-Signature: $timestamp_sign  <--- 时间戳签名,使用客户端 RSA 密钥,采用 SHA256withRSA 算法签名,使用 base64 编码。

WebSocket 会话开启后,即可进行业务通信。

4. 查询 WebSocket 会话状态

WebSocket 每次发送 C2S 包之前,建议验证当前 WebSocket 会话的状态。

访问 API GET service/rpc/session/check,请求添加同 3. 开启 WebSocket 会话 的 headers。

响应状态码如下:

4. 中断 WebSocket 会话

在任务已经完成后(例如机器人下线,机器人框架关闭等),需要主动中断 WebSocket 会话。

访问 API DELETE /service/rpc/session,请求添加同 3. 开启 WebSocket 会话 的 headers。

响应状态码如下:

WebSocket 通信格式

通用规范

{
  "packetId": "", // 独一无二的包 ID
  "packetType": "", // 包类型,对应为业务操作。
  ..., // 具体包类型的其他属性
}

packetId 有如下两种情况:

  1. C2S 包包含 packetId,此 C2S 包需要服务端回应,则该 S2C 包为此 C2S 包的回应包,packetId 为此 C2S 包的 packetId
  2. 若该 S2C 包的 packetTyperpc.service.send,表示此 S2C 包需要客户端回应,需要 C2S 包包含该 S2C 包的 packetId
{
    "packetId": .......,
    "packetType": "service.error",
    "message": "",
}

服务端不会主动发送业务错误的包,该 packetId 一定与 S2C 包的 packetId 对应。

业务场景

会话中断

S2C

{
    "packetType": "service.interrupt",
    "reason": "Interrupted by session invalidate",
}

客户端收到此包意味着当前 WebSocket session 已失效,需要重新握手获取新的 session。

初始化签名和加密服务

C2S

{
  "packetId": "",
  "packetType": "rpc.initialize",
  "extArgs": {
    "KEY_QIMEI36": "", // qimei 36
    "BOT_PROTOCOL": {
      "protocolValue": {
        "ver": "8.9.58",
      }
    }
  },
  "device": { // 除特殊标记,参数均为 value.toByteArray().hexString()
    "display": "",
    "product": "",
    "device": "",
    "board": "",
    "brand": "",
    "model": "",
    "bootloader": "",
    "fingerprint": "",
    "bootId": "", // raw string
    "procVersion": "",
    "baseBand": "",
    "version": {
      "incremental": "",
      "release": "",
      "codename": "",
      "sdk": 0 // int
    },
    "simInfo": "",
    "osType": "",
    "macAddress": "",
    "wifiBSSID": "",
    "wifiSSID": "",
    "imsiMd5": "",
    "imei": "", // raw string
    "apn": "",
    "androidId": "",
    "guid": ""
  },
},

S2C response

{
  "packetId": "",
  "packetType": "rpc.initialize"
}

初始化服务后才能进行 tlv 加密。

初始化过程中服务端会发送 rpc.service.send 包,详见服务端需要通过机器人框架发送包

获取 sso 签名白名单

C2S

{
  "packetId": "",
  "packetType": "rpc.get_cmd_white_list"
}

S2C response

{
  "packetId": "",
  "packetType": "rpc.get_cmd_white_list",
  "response": [
    "wtlogin.login",
    ...,
  ]
}

获取需要进行 sso 签名的包名单,帮助机器人框架判断机器人框架的网络包是否需要签名。

服务端需要通过机器人框架发送包

S2C

{
  "packetId": "server-...",
  "packetType": "rpc.service.send",
  "remark": "msf.security", // sso 包标记,可忽略
  "command": "trpc.o3.ecdh_access.EcdhAccess.SsoEstablishShareKey", // sso 包指令
  "botUin": 1234567890, // bot id
  "data": "" // RPC 服务端需要发送的包内容 bytes.hexString()
}

C2S response

{
  "packetId": "server-...",
  "packetType": "rpc.service.send",
  "command": "trpc.o3.ecdh_access.EcdhAccess.SsoEstablishShareKey",
  "data": "" // QQ 服务器包响应的内容 bytes.hexString()
}

客户端收到 rpc.service.send 后,需要将 data 包进行 sso 包装,通过机器人框架的网络层发送到 QQ 服务器。

QQ 服务器返回后,只需简单解析包的 command 等信息,将剩余内容传入 C2S response 包的 data

需要注意的是,服务端需要通过机器人框架发送包全部需要 sso 签名,所以请收到 rpc.service.send 包后调用 sso 签名对包装后的网络包进行签名。

sso 签名

C2S

{
  "packetId": "",
  "packetType": "rpc.sign",
  "seqId": 33782, // sso 包的 sequence id
  "command": "wtlogin.login", // sso 包指令
  "extArgs": {}, // 额外参数,为空
  "content": "" // sso 包内容 bytes.hexString()
}

S2C response

{
  "packetId": "",
  "packetType": "rpc.sign",
  "response": {
    "sign": "",
    "extra": "",
    "token": ""
  }
}

tlv 加密

C2S

{
  "packetId": "",
  "packetType": "rpc.tlv",
  "tlvType": 1348, // 0x544
  "extArgs": {
    "KEY_COMMAND_STR": "810_a"
  },
  "content": "" // t544 内容 bytes.hexString()
}

S2C response

{
  "packetId": "",
  "packetType": "rpc.tlv",
  "response": "" // 加密结果 bytes.hexString()
}