|
|
# OpenIM + Virgil E2EE 客户端伪代码模板
|
|
|
|
|
|
本文给出可直接改造成业务代码的四段伪代码模板:
|
|
|
|
|
|
1. 登录初始化
|
|
|
2. 单聊加密发送与接收解密
|
|
|
3. 群聊加密发送与接收解密
|
|
|
4. 群密钥版本追平(增量同步)
|
|
|
|
|
|
> 约定:
|
|
|
> - OpenIM SDK 负责业务消息收发、会话管理。
|
|
|
> - OpenIM CryptoService 负责设备注册、JWT、群版本事件。
|
|
|
> - Virgil E3Kit 负责本地加解密。
|
|
|
> - 服务端不解密消息明文。
|
|
|
|
|
|
---
|
|
|
|
|
|
## 0. 公共结构与工具函数
|
|
|
|
|
|
```typescript
|
|
|
type DeviceInfo = {
|
|
|
deviceID: string
|
|
|
platform: string
|
|
|
model: string
|
|
|
appVersion: string
|
|
|
}
|
|
|
|
|
|
type CipherEnvelope = {
|
|
|
alg: "virgil-e3kit-v1"
|
|
|
senderUserID: string
|
|
|
senderDeviceID: string
|
|
|
// base64 ciphertext
|
|
|
payload: string
|
|
|
// 扩展字段:会话ID、消息版本等
|
|
|
meta?: Record<string, string>
|
|
|
}
|
|
|
|
|
|
type GroupCipherEnvelope = {
|
|
|
alg: "virgil-group-v1"
|
|
|
groupID: string
|
|
|
groupKeyVersion: number
|
|
|
senderUserID: string
|
|
|
senderDeviceID: string
|
|
|
payload: string
|
|
|
meta?: Record<string, string>
|
|
|
}
|
|
|
|
|
|
function toBase64(input: Uint8Array): string { /* ... */ }
|
|
|
function fromBase64(input: string): Uint8Array { /* ... */ }
|
|
|
function nowMs(): number { return Date.now() }
|
|
|
|
|
|
function buildDeviceInfo(): DeviceInfo {
|
|
|
return {
|
|
|
deviceID: getStableDeviceID(), // 本地持久化的设备唯一ID
|
|
|
platform: getPlatformName(), // iOS/Android/Web/Desktop
|
|
|
model: getDeviceModel(),
|
|
|
appVersion: getAppVersion(),
|
|
|
}
|
|
|
}
|
|
|
```
|
|
|
|
|
|
---
|
|
|
|
|
|
## 1) 登录初始化(设备注册 + Virgil 初始化)
|
|
|
|
|
|
```typescript
|
|
|
async function loginAndInitE2EE(userID: string, token: string) {
|
|
|
// Step 1: 初始化 OpenIM SDK 登录态
|
|
|
await openim.login({ userID, token })
|
|
|
|
|
|
// Step 2: 注册设备(幂等)
|
|
|
const device = buildDeviceInfo()
|
|
|
await openim.crypto.registerDevice({
|
|
|
deviceID: device.deviceID,
|
|
|
platform: device.platform,
|
|
|
deviceModel: device.model,
|
|
|
appVersion: device.appVersion,
|
|
|
})
|
|
|
|
|
|
// Step 3: 获取 Virgil JWT(短期)
|
|
|
const jwtResp = await openim.crypto.getVirgilJWT({
|
|
|
deviceID: device.deviceID,
|
|
|
})
|
|
|
const virgilJWT = jwtResp.virgilJWT
|
|
|
const virgilIdentity = jwtResp.virgilIdentity // 一般是 userID:deviceID
|
|
|
|
|
|
// Step 4: 初始化 E3Kit(客户端本地)
|
|
|
const e3 = await E3Kit.initialize(async () => virgilJWT)
|
|
|
|
|
|
// Step 5: 首次设备场景(仅示例)
|
|
|
// - 若本地无私钥:生成并 publishCard
|
|
|
// - 若有 Keyknox 备份:restorePrivateKey
|
|
|
if (!(await e3.hasLocalPrivateKey())) {
|
|
|
await e3.register() // 内部通常会 publish card
|
|
|
}
|
|
|
|
|
|
// Step 6: 安全预检(可选但建议)
|
|
|
const precheck = await openim.crypto.securityPrecheck({
|
|
|
deviceID: device.deviceID,
|
|
|
action: "login_init_e2ee",
|
|
|
})
|
|
|
if (!precheck.allowed) {
|
|
|
throw new Error(`security precheck denied: ${precheck.reason}`)
|
|
|
}
|
|
|
|
|
|
// Step 7: 持有上下文
|
|
|
return {
|
|
|
userID,
|
|
|
deviceID: device.deviceID,
|
|
|
virgilIdentity,
|
|
|
e3,
|
|
|
}
|
|
|
}
|
|
|
```
|
|
|
|
|
|
---
|
|
|
|
|
|
## 2) 单聊发收(发送加密 / 接收解密)
|
|
|
|
|
|
### 2.1 发送单聊加密消息
|
|
|
|
|
|
```typescript
|
|
|
async function sendPrivateEncryptedText(
|
|
|
ctx: { userID: string; deviceID: string; e3: E3Kit },
|
|
|
toUserID: string,
|
|
|
plainText: string
|
|
|
) {
|
|
|
// 1) 拉取接收方 card(Virgil)
|
|
|
const recipientCard = await ctx.e3.findUsers(toUserID)
|
|
|
if (!recipientCard) throw new Error("recipient card not found")
|
|
|
|
|
|
// 2) 本地加密
|
|
|
const encrypted = await ctx.e3.encrypt(plainText, recipientCard)
|
|
|
|
|
|
// 3) 封装消息体(发给 OpenIM 的 content)
|
|
|
const envelope: CipherEnvelope = {
|
|
|
alg: "virgil-e3kit-v1",
|
|
|
senderUserID: ctx.userID,
|
|
|
senderDeviceID: ctx.deviceID,
|
|
|
payload: toBase64(encrypted),
|
|
|
}
|
|
|
|
|
|
// 4) 通过 OpenIM 普通消息通道发送(服务端仅转发存储密文)
|
|
|
await openim.sendMessage({
|
|
|
recvID: toUserID,
|
|
|
conversationType: "single",
|
|
|
contentType: "custom_e2ee_text", // 你项目定义的 contentType
|
|
|
content: JSON.stringify(envelope),
|
|
|
})
|
|
|
}
|
|
|
```
|
|
|
|
|
|
### 2.2 接收单聊加密消息并解密
|
|
|
|
|
|
```typescript
|
|
|
async function onPrivateMessageReceived(
|
|
|
ctx: { e3: E3Kit },
|
|
|
msg: { contentType: string; content: string; sendID: string }
|
|
|
) {
|
|
|
if (msg.contentType !== "custom_e2ee_text") return
|
|
|
|
|
|
const envelope = JSON.parse(msg.content) as CipherEnvelope
|
|
|
if (envelope.alg !== "virgil-e3kit-v1") return
|
|
|
|
|
|
// 1) 拉取发送方 card
|
|
|
const senderCard = await ctx.e3.findUsers(msg.sendID)
|
|
|
if (!senderCard) {
|
|
|
markMessageDecryptFailed(msg, "sender card not found")
|
|
|
return
|
|
|
}
|
|
|
|
|
|
// 2) 本地解密
|
|
|
try {
|
|
|
const plainText = await ctx.e3.decrypt(fromBase64(envelope.payload), senderCard)
|
|
|
renderMessage(msg, plainText)
|
|
|
} catch (err) {
|
|
|
markMessageDecryptFailed(msg, `decrypt failed: ${String(err)}`)
|
|
|
}
|
|
|
}
|
|
|
```
|
|
|
|
|
|
---
|
|
|
|
|
|
## 3) 群聊发收(发送加密 / 接收解密)
|
|
|
|
|
|
### 3.1 发送群聊加密消息
|
|
|
|
|
|
```typescript
|
|
|
async function sendGroupEncryptedText(
|
|
|
ctx: { userID: string; deviceID: string; e3: E3Kit },
|
|
|
groupID: string,
|
|
|
plainText: string
|
|
|
) {
|
|
|
// 1) 先确保本地群密钥版本已追平(见第4段)
|
|
|
const version = await ensureGroupKeySynced(ctx, groupID)
|
|
|
|
|
|
// 2) 获取/创建本地 group session(示意)
|
|
|
const groupSession = await getOrCreateGroupSession(ctx.e3, groupID, version)
|
|
|
|
|
|
// 3) 本地加密
|
|
|
const encrypted = await groupSession.encrypt(plainText)
|
|
|
|
|
|
// 4) 封装消息体
|
|
|
const envelope: GroupCipherEnvelope = {
|
|
|
alg: "virgil-group-v1",
|
|
|
groupID,
|
|
|
groupKeyVersion: version,
|
|
|
senderUserID: ctx.userID,
|
|
|
senderDeviceID: ctx.deviceID,
|
|
|
payload: toBase64(encrypted),
|
|
|
}
|
|
|
|
|
|
// 5) 发送到 OpenIM
|
|
|
await openim.sendMessage({
|
|
|
recvID: groupID,
|
|
|
conversationType: "group",
|
|
|
contentType: "custom_e2ee_group_text",
|
|
|
content: JSON.stringify(envelope),
|
|
|
})
|
|
|
}
|
|
|
```
|
|
|
|
|
|
### 3.2 接收群聊加密消息并解密
|
|
|
|
|
|
```typescript
|
|
|
async function onGroupMessageReceived(
|
|
|
ctx: { e3: E3Kit },
|
|
|
msg: { groupID: string; contentType: string; content: string }
|
|
|
) {
|
|
|
if (msg.contentType !== "custom_e2ee_group_text") return
|
|
|
|
|
|
const envelope = JSON.parse(msg.content) as GroupCipherEnvelope
|
|
|
if (envelope.alg !== "virgil-group-v1") return
|
|
|
|
|
|
// 1) 若消息携带版本比本地新,先追平
|
|
|
const localVersion = await loadLocalGroupKeyVersion(msg.groupID)
|
|
|
if (envelope.groupKeyVersion > localVersion) {
|
|
|
await ensureGroupKeySynced({ e3: ctx.e3 } as any, msg.groupID)
|
|
|
}
|
|
|
|
|
|
// 2) 取本地会话并解密
|
|
|
try {
|
|
|
const groupSession = await getOrCreateGroupSession(ctx.e3, msg.groupID, envelope.groupKeyVersion)
|
|
|
const plainText = await groupSession.decrypt(fromBase64(envelope.payload))
|
|
|
renderMessage(msg, plainText)
|
|
|
} catch (err) {
|
|
|
markMessageDecryptFailed(msg, `group decrypt failed: ${String(err)}`)
|
|
|
}
|
|
|
}
|
|
|
```
|
|
|
|
|
|
---
|
|
|
|
|
|
## 4) 群密钥版本追平(增量同步模板)
|
|
|
|
|
|
```typescript
|
|
|
async function ensureGroupKeySynced(
|
|
|
ctx: { userID?: string; deviceID?: string; e3: E3Kit },
|
|
|
groupID: string
|
|
|
): Promise<number> {
|
|
|
// A) 服务端当前版本
|
|
|
const latestResp = await openim.crypto.getGroupKeyVersion({ groupID })
|
|
|
const latestVersion = Number(latestResp.groupKeyVersion || 0)
|
|
|
|
|
|
// B) 本地版本
|
|
|
let localVersion = await loadLocalGroupKeyVersion(groupID) // 默认 0
|
|
|
if (localVersion >= latestVersion) return localVersion
|
|
|
|
|
|
// C) 拉取增量事件
|
|
|
const eventsResp = await openim.crypto.getGroupKeyEvents({
|
|
|
groupID,
|
|
|
sinceVersion: localVersion,
|
|
|
})
|
|
|
const events = eventsResp.events || []
|
|
|
|
|
|
// D) 按版本顺序应用事件(关键)
|
|
|
events.sort((a, b) => Number(a.groupKeyVersion) - Number(b.groupKeyVersion))
|
|
|
for (const ev of events) {
|
|
|
const targetVersion = Number(ev.groupKeyVersion)
|
|
|
if (targetVersion <= localVersion) continue
|
|
|
|
|
|
// 示例:根据事件刷新 group ticket / 重新分发会话材料
|
|
|
// 具体实现取决于你对 Virgil Group Tickets 的封装
|
|
|
await applyGroupKeyEvent(ctx.e3, groupID, {
|
|
|
eventType: ev.eventType,
|
|
|
operatorUserID: ev.operatorUserID,
|
|
|
targetVersion,
|
|
|
})
|
|
|
|
|
|
localVersion = targetVersion
|
|
|
await saveLocalGroupKeyVersion(groupID, localVersion)
|
|
|
}
|
|
|
|
|
|
// E) 防御性校验
|
|
|
if (localVersion < latestVersion) {
|
|
|
// 说明事件缺失,可做一次全量恢复
|
|
|
await rebuildGroupSessionFromSource(ctx.e3, groupID, latestVersion)
|
|
|
localVersion = latestVersion
|
|
|
await saveLocalGroupKeyVersion(groupID, localVersion)
|
|
|
}
|
|
|
|
|
|
return localVersion
|
|
|
}
|
|
|
```
|
|
|
|
|
|
---
|
|
|
|
|
|
## 5) 建议落地策略(简版)
|
|
|
|
|
|
- **密钥刷新时机**:应用启动、会话进入、收到群消息且版本落后、网络重连后。
|
|
|
- **失败重试**:`get_virgil_jwt`、`get_group_key_events` 使用指数退避重试。
|
|
|
- **本地缓存**:按 `groupID -> groupKeyVersion` 持久化,避免重复全量同步。
|
|
|
- **消息兼容**:保留 `alg` 字段,支持后续算法版本升级。
|
|
|
- **安全日志**:不要记录明文与私钥,仅记录 `groupID/userID/version/eventType`。
|
|
|
|
|
|
---
|
|
|
|
|
|
## 6) 与当前服务端接口对应
|
|
|
|
|
|
当前服务端公开路由(`internal/api/router.go`):
|
|
|
|
|
|
- `/crypto/register_device`
|
|
|
- `/crypto/get_devices`
|
|
|
- `/crypto/revoke_device`
|
|
|
- `/crypto/get_virgil_jwt`
|
|
|
- `/crypto/get_group_key_version`
|
|
|
- `/crypto/get_group_key_events`
|
|
|
- `/crypto/security_precheck`
|
|
|
- `/crypto/integrity_report`
|
|
|
|
|
|
> `bump_group_key_version` 为内部服务调用,不提供给客户端公开使用。
|
|
|
|