diff --git a/docs/virgil-e2ee-client-pseudocode.md b/docs/virgil-e2ee-client-pseudocode.md new file mode 100644 index 000000000..58e8dd79e --- /dev/null +++ b/docs/virgil-e2ee-client-pseudocode.md @@ -0,0 +1,332 @@ +# 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 +} + +type GroupCipherEnvelope = { + alg: "virgil-group-v1" + groupID: string + groupKeyVersion: number + senderUserID: string + senderDeviceID: string + payload: string + meta?: Record +} + +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 { + // 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` 为内部服务调用,不提供给客户端公开使用。 +