open-im-server/docs/virgil-e2ee-single-group-minimal-design.md
2026-04-16 11:25:45 +08:00

19 KiB
Raw Blame History

基于 Virgil Security E3Kit 的单聊 & 群聊最小落地方案

一、核心设计原则

原则 说明
服务端零知情 服务端只接触密文、元数据与业务控制,永远不触碰明文、私钥
客户端加解密 所有加密/解密/签名/验签均在客户端完成,使用 Virgil E3Kit SDK
Virgil Cloud 托管公钥 用户公钥Virgil Card存储在 Virgil Cloud服务端不保存
JWT 桥接认证 服务端用 Virgil App Key 签发 Virgil JWT客户端持 JWT 与 Virgil Cloud 交互

二、整体架构图

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(最小 MVPDouble Ratchet(增强版,提供前向保密)。

MVP 阶段推荐 Default Encryption

特性 Default Encryption Double Ratchet
前向保密
实现复杂度
平台支持 JS/Swift/Kotlin Swift/KotlinJS 暂不支持)
适用场景 MVP 快速落地 安全性要求高的正式版

3.2 单聊时序图

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<br/>MsgData.contentType = E2EE_TEXT<br/>MsgData.ex = {"cipher_suite":"ed25519/aes256-gcm"}

    S->>S: 校验身份/会话/幂等<br/>分配 serverMsgID + seq<br/>存储密文到 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,加密信息通过现有字段承载:

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 群聊时序图 — 建群与首条消息

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: 生成群共享密钥<br/>票据上传 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 是群成员<br/>group_key_version 合法<br/>存储密文, 分配 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 群成员变更与密钥轮转时序图

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 更新群票据<br/>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<br/>后续消息 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,仅携带 conversationIDsenderNickname、占位提示

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 APIGateway 暴露)

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<ID, Card> 批量查找用户公钥,结果缓存
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,对比本地版本决定是否更新

七、数据模型(服务端新增表)

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

{
  "envelope_version": 1,
  "cipher_suite": "ed25519/aes256-gcm",
  "group_key_version": 2,
  "sender_device_id": "ios_a1"
}

九、实施路线(分两个阶段)

阶段 1单聊 MVP约 2-3 周)

服务端:
  ├── 实现 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 周)

服务端:
  ├── 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

参考资料