# 基于 Virgil Security E3Kit 的单聊 & 群聊最小落地方案 ## 一、核心设计原则 | 原则 | 说明 | |---|---| | **服务端零知情** | 服务端只接触密文、元数据与业务控制,永远不触碰明文、私钥 | | **客户端加解密** | 所有加密/解密/签名/验签均在客户端完成,使用 Virgil E3Kit SDK | | **Virgil Cloud 托管公钥** | 用户公钥(Virgil Card)存储在 Virgil Cloud,服务端不保存 | | **JWT 桥接认证** | 服务端用 Virgil App Key 签发 Virgil JWT,客户端持 JWT 与 Virgil Cloud 交互 | ## 二、整体架构图 ```mermaid flowchart TB subgraph Client["客户端 (iOS/Android/Web)"] C1["E3Kit SDK"] C2["IM SDK"] C3["本地密钥存储"] end subgraph Server["OpenIM 服务端"] S1["API Gateway"] S2["Auth Service"] S3["CryptoService\n(新增 gRPC)"] S4["Msg Service"] S5["Group Service"] S6["Push Service"] S7["Sync / MsgTransfer"] DB["MongoDB / Redis"] end subgraph Virgil["Virgil Cloud"] V1["Cards Service\n(公钥目录)"] V2["Keyknox\n(密钥备份)"] V3["Group Tickets\n(群密钥票据)"] end C2 -->|"WebSocket / HTTP\n(密文 envelope)"| S1 S1 --> S2 S1 --> S3 S1 --> S4 S1 --> S5 S4 --> S7 S7 --> DB S4 --> S6 C1 -->|"Virgil JWT"| V1 C1 -->|"密钥备份/恢复"| V2 C1 -->|"群票据同步"| V3 S3 -->|"签发 JWT\n(Virgil App Key)"| C1 S5 -->|"群事件通知"| S6 ``` **图意说明:** 1. 客户端持有 E3Kit SDK(负责加解密)和 IM SDK(负责业务通信),私钥存储在设备本地。 2. 服务端新增 `CryptoService` gRPC 服务,核心职责是签发 Virgil JWT 和管理群密钥版本号。 3. Virgil Cloud 承担公钥目录、密钥备份、群加密票据的托管。 4. 消息流:客户端加密 -> 密文 envelope 经 WebSocket/HTTP 到服务端 -> 服务端存储密文+路由 -> 接收端拉取密文 -> 客户端解密。 5. 服务端全程不接触明文,只处理密文 bytes 和元数据。 ## 三、单聊方案 ### 3.1 单聊加密模型 单聊使用 E3Kit 的 **Default Encryption**(最小 MVP)或 **Double Ratchet**(增强版,提供前向保密)。 MVP 阶段推荐 Default Encryption: | 特性 | Default Encryption | Double Ratchet | |---|---|---| | 前向保密 | 否 | 是 | | 实现复杂度 | 低 | 中 | | 平台支持 | JS/Swift/Kotlin | Swift/Kotlin(JS 暂不支持) | | 适用场景 | MVP 快速落地 | 安全性要求高的正式版 | ### 3.2 单聊时序图 ```mermaid sequenceDiagram participant A as Alice (客户端) participant S as OpenIM Server participant CS as CryptoService participant VC as Virgil Cloud participant B as Bob (客户端) Note over A,B: === 阶段 1: 初始化 (首次登录) === A->>S: POST /auth/login S-->>A: access_token A->>CS: RegisterDevice(userID, deviceID) CS-->>A: DeviceInfo A->>CS: GetVirgilJWT(userID, deviceID) CS-->>A: virgil_jwt A->>VC: E3Kit.init(virgilJWT) -> register() VC-->>A: 生成密钥对, 发布公钥 Card Note over A,B: === 阶段 2: Alice 给 Bob 发加密消息 === A->>VC: findUsers(["bob"]) VC-->>A: Bob 的公钥 Card A->>A: E3Kit.authEncrypt("Hello", bobCard) Note right of A: 用 Bob 公钥加密 + Alice 私钥签名 A->>S: SendMsg(密文 envelope) Note right of A: MsgData.content = ciphertext bytes
MsgData.contentType = E2EE_TEXT
MsgData.ex = {"cipher_suite":"ed25519/aes256-gcm"} S->>S: 校验身份/会话/幂等
分配 serverMsgID + seq
存储密文到 MongoDB S-->>A: SendMsgResp(serverMsgID, seq) S->>B: WebSocket push: message.new Note right of S: 推送不含明文 Note over A,B: === 阶段 3: Bob 拉取并解密 === B->>S: PullMessageBySeqs(seq) S-->>B: MsgData(密文 envelope) B->>VC: findUsers(["alice"]) VC-->>B: Alice 的公钥 Card B->>B: E3Kit.authDecrypt(ciphertext, aliceCard) Note right of B: 用 Bob 私钥解密 + Alice 公钥验签 B->>B: 显示明文 "Hello" ``` **关键说明:** - 边界条件:`findUsers` 结果应在客户端缓存,避免每条消息都查询 Virgil Cloud。 - 异常路径:若 Bob 的 Card 不存在(未注册 E3Kit),消息无法加密,客户端应提示“对方尚未启用加密”。 - 幂等性:消息幂等键 = `sendID + deviceID + clientMsgID`,服务端去重。 - 性能:E3Kit 加密单条消息主要是本地计算,通常瓶颈在网络链路而非加密。 ### 3.3 单聊消息 Envelope 结构 消息体复用 OpenIM 现有的 `sdkws.MsgData`,加密信息通过现有字段承载: ```protobuf message MsgData { string sendID = 1; string recvID = 2; string clientMsgID = 4; int32 sessionType = 9; // 1=单聊 int32 contentType = 11; // 新增: 2001=E2EE_TEXT, 2002=E2EE_IMAGE, ... bytes content = 12; // 密文 ciphertext (E3Kit 加密输出) string ex = 23; // JSON: {"envelope_version":1, "cipher_suite":"ed25519/aes256-gcm"} // ... 其余字段不变 } ``` 不需要修改 proto 定义,只需约定 `contentType` 新值和 `ex` 字段的 JSON schema。 ## 四、群聊方案 ### 4.1 群聊加密模型 群聊使用 E3Kit 的 **Group Encryption**: - 群主创建群时通过 `E3Kit.createGroup(groupId, members)` 生成群共享密钥票据。 - 票据存储在 Virgil Cloud,群成员通过 `E3Kit.loadGroup(groupId, ownerCard)` 加载。 - 新成员加入后通过 `group.add(newMemberCard)` 获得访问历史消息的能力。 - 成员移除后通过 `group.remove(memberCard)` 撤销访问权限,群密钥自动轮转。 ### 4.2 群聊时序图 — 建群与首条消息 ```mermaid sequenceDiagram participant Owner as 群主 Alice participant S as OpenIM Server participant GS as Group Service participant CS as CryptoService participant VC as Virgil Cloud participant M1 as 成员 Bob participant M2 as 成员 Carol Note over Owner,M2: === 阶段 1: 创建群 === Owner->>GS: CreateGroup({members:[Bob,Carol]}) GS->>GS: 创建群记录, group_key_version=1 GS-->>Owner: {groupID, group_key_version:1} Owner->>VC: findUsers(["bob","carol"]) VC-->>Owner: Bob & Carol 的 Cards Owner->>VC: E3Kit.createGroup(groupID, [bobCard, carolCard]) Note right of Owner: 生成群共享密钥
票据上传 Virgil Cloud VC-->>Owner: Group 对象 GS->>M1: 推送 group.created 通知 GS->>M2: 推送 group.created 通知 Note over Owner,M2: === 阶段 2: 成员加载群密钥 === M1->>VC: findUsers(["alice"]) VC-->>M1: Alice (Owner) 的 Card M1->>VC: E3Kit.loadGroup(groupID, aliceCard) VC-->>M1: Group 对象 (本地缓存) M2->>VC: E3Kit.loadGroup(groupID, aliceCard) VC-->>M2: Group 对象 Note over Owner,M2: === 阶段 3: Alice 发送群加密消息 === Owner->>Owner: group.encrypt("大家好!") Owner->>S: SendMsg(密文 envelope, groupID, group_key_version=1) S->>S: 校验 Alice 是群成员
group_key_version 合法
存储密文, 分配 seq S-->>Owner: SendMsgResp S->>M1: WebSocket push S->>M2: WebSocket push M1->>S: PullMessageBySeqs S-->>M1: MsgData(密文) M1->>VC: findUsers(["alice"]) M1->>M1: group.decrypt(ciphertext, aliceCard) M1->>M1: 显示 "大家好!" ``` ### 4.3 群成员变更与密钥轮转时序图 ```mermaid sequenceDiagram participant Owner as 群主 Alice participant S as OpenIM Server participant GS as Group Service participant CS as CryptoService participant VC as Virgil Cloud participant New as 新成员 Dan participant M1 as 成员 Bob Note over Owner,M1: === 加人场景 === Owner->>GS: InviteUserToGroup(groupID, [Dan]) GS->>GS: 添加 Dan 到群成员 GS->>CS: BumpGroupKeyVersion(groupID, "member_added") CS-->>GS: {group_key_version: 2} GS-->>Owner: {group_key_version: 2} Owner->>VC: findUsers(["dan"]) VC-->>Owner: Dan 的 Card Owner->>VC: group.add(danCard) Note right of Owner: Virgil Cloud 更新群票据
Dan 可解密历史消息 GS->>New: 推送 group.member_changed GS->>M1: 推送 group.member_changed (含新 version) New->>VC: E3Kit.loadGroup(groupID, ownerCard) VC-->>New: Group 对象 M1->>VC: group.update() Note right of M1: 拉取最新群票据 Note over Owner,M1: === 踢人场景 === Owner->>GS: KickGroupMember(groupID, [Dan]) GS->>GS: 移除 Dan GS->>CS: BumpGroupKeyVersion(groupID, "member_removed") CS-->>GS: {group_key_version: 3} Owner->>VC: group.remove(danCard) Note right of Owner: Dan 无法再 loadGroup
后续消息 Dan 无法解密 GS->>M1: 推送 group.member_changed M1->>VC: group.update() ``` **关键说明:** - 群密钥版本:每次成员变更,服务端 `group_key_version` +1,客户端据此判断是否需要 `group.update()`。 - 加人:新成员可解密加入前的历史消息(E3Kit Group 设计)。 - 踢人:被踢成员无法解密踢出后的新消息,但仍可解密踢出前已获取的消息(E2EE 的固有限制)。 - 并发:多个管理员同时操作成员时,`BumpGroupKeyVersion` 使用数据库原子递增保证版本一致。 - 性能:`group.update()` 涉及一次 Virgil Cloud 请求,建议客户端在收到 `group.member_changed` 通知后异步执行。 ## 五、服务端接口清单 ### 5.1 CryptoService(新增 gRPC 服务) 基于已有 `protocol/crypto/crypto.proto` 定义: | RPC 接口 | 请求 | 响应 | 职责说明 | |---|---|---|---| | `RegisterDevice` | `RegisterDeviceReq` | `RegisterDeviceResp` | 注册设备,建立 `userID -> deviceID -> virgilIdentity` 映射 | | `GetDevices` | `GetDevicesReq` | `GetDevicesResp` | 查询用户所有已注册设备 | | `RevokeDevice` | `RevokeDeviceReq` | `RevokeDeviceResp` | 吊销设备,标记为 inactive | | `GetVirgilJWT` | `GetVirgilJWTReq` | `GetVirgilJWTResp` | 为合法设备签发 Virgil JWT(核心接口) | | `GetGroupKeyVersion` | `GetGroupKeyVersionReq` | `GetGroupKeyVersionResp` | 查询群当前密钥版本号 | | `BumpGroupKeyVersion` | `BumpGroupKeyVersionReq` | `BumpGroupKeyVersionResp` | 群成员变更时递增密钥版本(Group Service 内部调用) | | `GetGroupKeyEvents` | `GetGroupKeyEventsReq` | `GetGroupKeyEventsResp` | 查询密钥版本变更历史(客户端增量同步) | | `SecurityPrecheck` | `SecurityPrecheckReq` | `SecurityPrecheckResp` | 安全前置校验(设备状态/风控) | | `IntegrityReport` | `IntegrityReportReq` | `IntegrityReportResp` | 设备完整性上报 | ### 5.2 现有服务需要的改动 #### Auth Service | 改动点 | 说明 | |---|---| | 登录响应增加字段 | 在 `ex` 或扩展字段中返回 `e2ee_enabled: true`,提示客户端初始化 E3Kit | #### Msg Service | 改动点 | 说明 | |---|---| | `SendMsg` 校验逻辑 | 当 `contentType` 位于 E2EE 区间时,跳过明文内容校验,仅校验 ciphertext 长度上限 | | 消息存储 | `content` 字段直接存储密文 bytes,沿用现有存储路径 | | 推送通知 | 推送 payload 中不携带 `content`,仅携带 `conversationID`、`senderNickname`、占位提示 | #### Group Service | 改动点 | 说明 | |---|---| | `CreateGroup` | 创建群时初始化 `group_key_version = 1` | | `InviteUserToGroup` | 成功后调用 `CryptoService.BumpGroupKeyVersion(eventType="member_added")` | | `KickGroupMember` | 成功后调用 `CryptoService.BumpGroupKeyVersion(eventType="member_removed")` | | `QuitGroup` | 成功后调用 `CryptoService.BumpGroupKeyVersion(eventType="member_left")` | | 通知 payload | `group.member_changed` 通知中携带最新 `group_key_version` | ### 5.3 服务端 HTTP API(Gateway 暴露) ```text POST /api/v1/crypto/device/register -> CryptoService.RegisterDevice GET /api/v1/crypto/devices -> CryptoService.GetDevices POST /api/v1/crypto/device/revoke -> CryptoService.RevokeDevice POST /api/v1/crypto/virgil-jwt -> CryptoService.GetVirgilJWT GET /api/v1/crypto/group-key-version -> CryptoService.GetGroupKeyVersion POST /api/v1/crypto/group-key-version/bump -> CryptoService.BumpGroupKeyVersion GET /api/v1/crypto/group-key-events -> CryptoService.GetGroupKeyEvents POST /api/v1/crypto/security-precheck -> CryptoService.SecurityPrecheck POST /api/v1/crypto/integrity-report -> CryptoService.IntegrityReport ``` ## 六、客户端接口清单 ### 6.1 E3Kit 封装层接口 | 接口 | 输入 | 输出 | 说明 | |---|---|---|---| | `initialize(tokenCallback)` | JWT 获取回调 | void | 初始化 E3Kit,设置 JWT 刷新回调 | | `register()` | - | void | 首次注册:生成密钥对,发布 Virgil Card | | `restorePrivateKey(password)` | 备份密码 | void | 从 Virgil Keyknox 恢复私钥(换设备场景) | | `backupPrivateKey(password)` | 备份密码 | void | 备份私钥到 Virgil Keyknox | | `findUsers(userIDs)` | 用户 ID 列表 | Map | 批量查找用户公钥,结果缓存 | | `cleanup()` | - | void | 登出时清理本地私钥 | | `rotatePrivateKey()` | - | void | 私钥泄露时轮换密钥对 | ### 6.2 单聊加解密接口 | 接口 | 输入 | 输出 | 说明 | |---|---|---|---| | `encryptForUser(plaintext, recipientCard)` | 明文 + 接收者 Card | 密文 string | 用接收者公钥加密 + 发送者私钥签名 | | `decryptFromUser(ciphertext, senderCard)` | 密文 + 发送者 Card | 明文 string | 用本地私钥解密 + 发送者公钥验签 | | `encryptFileForUser(inputStream, recipientCard)` | 文件流 + Card | 加密流 | 大文件加密 | | `decryptFileFromUser(inputStream, senderCard)` | 加密流 + Card | 明文流 | 大文件解密 | ### 6.3 群聊加解密接口 | 接口 | 输入 | 输出 | 说明 | |---|---|---|---| | `createGroup(groupID, memberCards)` | 群 ID + 成员 Cards | Group 对象 | 群主创建群加密上下文 | | `loadGroup(groupID, ownerCard)` | 群 ID + 群主 Card | Group 对象 | 非群主加载群加密上下文 | | `getGroup(groupID)` | 群 ID | Group / null | 从本地缓存获取群对象 | | `updateGroup(groupID)` | 群 ID | void | 拉取最新群票据(成员变更后调用) | | `addGroupMember(groupID, newMemberCard)` | 群 ID + 新成员 Card | void | 群主添加成员到加密上下文 | | `removeGroupMember(groupID, memberCard)` | 群 ID + 成员 Card | void | 群主从加密上下文移除成员 | | `deleteGroup(groupID)` | 群 ID | void | 群主删除群加密上下文 | | `encryptForGroup(plaintext, group)` | 明文 + Group | 密文 string | 群消息加密 | | `decryptFromGroup(ciphertext, senderCard, group)` | 密文 + 发送者 Card + Group | 明文 string | 群消息解密 + 验签 | ### 6.4 IM SDK 业务层接口 | 接口 | 说明 | |---|---| | `requestVirgilJWT()` | 调用服务端 `/crypto/virgil-jwt`,获取并缓存 Virgil JWT | | `registerDevice()` | 调用服务端 `/crypto/device/register` | | `sendEncryptedMessage(conversationID, plaintext)` | 加密 -> 构造 `MsgData`(E2EE contentType) -> `SendMsg` | | `onReceiveEncryptedMessage(msgData)` | 判断 contentType -> 查找 senderCard -> 解密 -> 回调 UI | | `onGroupMemberChanged(groupID, newVersion)` | 收到通知后调用 `updateGroup()` 刷新群票据 | | `syncGroupKeyVersion(groupID)` | 调用服务端 `/crypto/group-key-version`,对比本地版本决定是否更新 | ## 七、数据模型(服务端新增表) ```mermaid erDiagram DEVICE ||--|| USER : belongs_to DEVICE { string device_id PK string user_id FK string platform string device_model string app_version string virgil_identity string status "active / revoked" int64 last_seen_at int64 create_time } GROUP_KEY_VERSION ||--|| GROUP : tracks GROUP_KEY_VERSION { string group_id PK int64 group_key_version "原子递增" } GROUP_KEY_EVENT }o--|| GROUP : belongs_to GROUP_KEY_EVENT { string event_id PK string group_id FK int64 group_key_version string event_type "member_added/removed/left" string operator_user_id int64 create_time } ``` ## 八、contentType 约定 复用 OpenIM 现有 `contentType` 编码空间,为 E2EE 消息分配新区间: | contentType | 名称 | 说明 | |---|---|---| | 2001 | `E2EE_TEXT` | 端到端加密文本 | | 2002 | `E2EE_IMAGE` | 端到端加密图片(密文 content + 加密缩略图) | | 2003 | `E2EE_VIDEO` | 端到端加密视频 | | 2004 | `E2EE_FILE` | 端到端加密文件 | | 2005 | `E2EE_AUDIO` | 端到端加密语音 | | 2006 | `E2EE_LOCATION` | 端到端加密位置 | | 2099 | `E2EE_CUSTOM` | 端到端加密自定义消息 | `ex` 字段 JSON schema: ```json { "envelope_version": 1, "cipher_suite": "ed25519/aes256-gcm", "group_key_version": 2, "sender_device_id": "ios_a1" } ``` ## 九、实施路线(分两个阶段) ### 阶段 1:单聊 MVP(约 2-3 周) ```text 服务端: ├── 实现 CryptoService gRPC (internal/rpc/crypto/) │ ├── RegisterDevice / GetDevices / RevokeDevice │ ├── GetVirgilJWT (核心: 用 Virgil App Key 签发) │ └── SecurityPrecheck ├── API Gateway 新增 /crypto/* 路由 ├── Msg Service: E2EE contentType 跳过明文校验 └── Push Service: E2EE 消息推送不含 content 客户端: ├── 集成 E3Kit SDK ├── 实现 E2EEManager (initialize/register/findUsers) ├── 实现单聊 encryptForUser / decryptFromUser ├── IM SDK 封装 sendEncryptedMessage / onReceiveEncryptedMessage └── UI: 加密消息标识 (锁图标) ``` ### 阶段 2:群聊(约 2-3 周) ```text 服务端: ├── CryptoService 补充: GetGroupKeyVersion / BumpGroupKeyVersion / GetGroupKeyEvents ├── Group Service 联动: 成员变更时 BumpGroupKeyVersion ├── GROUP_KEY_VERSION / GROUP_KEY_EVENT 表 └── 通知 payload 携带 group_key_version 客户端: ├── E2EEManager 补充群聊接口 (createGroup/loadGroup/addMember/removeMember) ├── 实现 encryptForGroup / decryptFromGroup ├── onGroupMemberChanged -> group.update() └── 群聊 UI: 显示加密状态 / 密钥版本 ``` ## 十、安全风险与缓解 | 风险 | 影响 | 缓解措施 | |---|---|---| | 服务端日志泄露明文 | 破坏 E2EE 边界 | 服务端 `content` 字段日志脱敏,E2EE 类型消息禁止打印 `content` | | 被吊销设备仍获取 JWT | 安全失控 | `GetVirgilJWT` 必须校验设备 `status=active` | | 推送携带明文 | 绕过加密 | Push payload 仅含 `conversationID` + 占位提示 | | 群成员变更后未更新群票据 | 用旧密钥加密 | 客户端发送前 `syncGroupKeyVersion`,版本不一致先 `update` | | 私钥丢失 | 无法解密历史 | 引导用户 `backupPrivateKey`,换设备时 `restorePrivateKey` | ## 参考资料 - [Virgil Security Documentation](https://developer.virgilsecurity.com/) - [E3Kit Quickstart](https://developer.virgilsecurity.com/docs/e3kit/get-started/quickstart) - [Generate Client Tokens](https://developer.virgilsecurity.com/docs/e3kit/get-started/generate-client-tokens) - [User Authentication](https://developer.virgilsecurity.com/docs/e3kit/user-authentication/) - [Group Encryption](https://developer.virgilsecurity.com/docs/e3kit/end-to-end-encryption/group-chat) - [Double Ratchet Encryption](https://developer.virgilsecurity.com/docs/e3kit/end-to-end-encryption/double-ratchet)