You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
9.1 KiB
9.1 KiB
OpenIM + Virgil E2EE 客户端伪代码模板
本文给出可直接改造成业务代码的四段伪代码模板:
- 登录初始化
- 单聊加密发送与接收解密
- 群聊加密发送与接收解密
- 群密钥版本追平(增量同步)
约定:
- OpenIM SDK 负责业务消息收发、会话管理。
- OpenIM CryptoService 负责设备注册、JWT、群版本事件。
- Virgil E3Kit 负责本地加解密。
- 服务端不解密消息明文。
0. 公共结构与工具函数
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 初始化)
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 发送单聊加密消息
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 接收单聊加密消息并解密
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 发送群聊加密消息
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 接收群聊加密消息并解密
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) 群密钥版本追平(增量同步模板)
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为内部服务调用,不提供给客户端公开使用。