From 75b2c700f38c7ab2341375394df629ed62ef3022 Mon Sep 17 00:00:00 2001
From: hawklin2017 <32898629+hawklin2017@users.noreply.github.com>
Date: Thu, 16 Apr 2026 11:25:45 +0800
Subject: [PATCH] =?UTF-8?q?virgil=20=E5=8A=A0=E5=AF=86=E8=A7=A3=E5=AF=86?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
cmd/openim-rpc/openim-rpc-crypto/main.go | 12 +
config/openim-rpc-crypto.yml | 14 +
config/share.yml | 1 +
...virgil-e2ee-single-group-minimal-design.md | 501 +++++++++++++
go.mod | 3 +
go.sum | 6 +
internal/api/crypto.go | 47 ++
internal/api/router.go | 19 +
internal/rpc/crypto/crypto.go | 496 +++++++++++++
internal/rpc/group/group.go | 13 +
pkg/common/cmd/constant.go | 4 +-
pkg/common/cmd/rpc_crypto.go | 47 ++
pkg/common/config/config.go | 24 +
pkg/common/storage/controller/crypto.go | 133 ++++
pkg/common/storage/database/crypto.go | 25 +
pkg/common/storage/database/mgo/crypto.go | 183 +++++
pkg/common/storage/model/crypto.go | 29 +
pkg/rpcli/crypto.go | 51 ++
virgil_chat_server_design.md | 675 ++++++++++++++++++
19 files changed, 2282 insertions(+), 1 deletion(-)
create mode 100644 cmd/openim-rpc/openim-rpc-crypto/main.go
create mode 100644 config/openim-rpc-crypto.yml
create mode 100644 docs/virgil-e2ee-single-group-minimal-design.md
create mode 100644 internal/api/crypto.go
create mode 100644 internal/rpc/crypto/crypto.go
create mode 100644 pkg/common/cmd/rpc_crypto.go
create mode 100644 pkg/common/storage/controller/crypto.go
create mode 100644 pkg/common/storage/database/crypto.go
create mode 100644 pkg/common/storage/database/mgo/crypto.go
create mode 100644 pkg/common/storage/model/crypto.go
create mode 100644 pkg/rpcli/crypto.go
create mode 100644 virgil_chat_server_design.md
diff --git a/cmd/openim-rpc/openim-rpc-crypto/main.go b/cmd/openim-rpc/openim-rpc-crypto/main.go
new file mode 100644
index 000000000..c2debee6e
--- /dev/null
+++ b/cmd/openim-rpc/openim-rpc-crypto/main.go
@@ -0,0 +1,12 @@
+package main
+
+import (
+ "github.com/openimsdk/open-im-server/v3/pkg/common/cmd"
+ "github.com/openimsdk/tools/system/program"
+)
+
+func main() {
+ if err := cmd.NewCryptoRpcCmd().Exec(); err != nil {
+ program.ExitWithError(err)
+ }
+}
diff --git a/config/openim-rpc-crypto.yml b/config/openim-rpc-crypto.yml
new file mode 100644
index 000000000..d81244f37
--- /dev/null
+++ b/config/openim-rpc-crypto.yml
@@ -0,0 +1,14 @@
+rpc:
+ registerIP: ''
+ listenIP: 0.0.0.0
+ autoSetPorts: true
+ ports:
+ - 10190
+prometheus:
+ enable: true
+ ports:
+ - 20190
+virgil:
+ appID: ''
+ appKey: ''
+ appKeyID: ''
diff --git a/config/share.yml b/config/share.yml
index a953193d8..610bad52f 100644
--- a/config/share.yml
+++ b/config/share.yml
@@ -11,6 +11,7 @@ rpcRegisterName:
third: third
captcha: captcha
rtc: rtc
+ crypto: crypto
imAdminUserID: [ imAdmin ]
diff --git a/docs/virgil-e2ee-single-group-minimal-design.md b/docs/virgil-e2ee-single-group-minimal-design.md
new file mode 100644
index 000000000..9a8d1cb24
--- /dev/null
+++ b/docs/virgil-e2ee-single-group-minimal-design.md
@@ -0,0 +1,501 @@
+# 基于 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)
diff --git a/go.mod b/go.mod
index b7c07e7a6..6f54e0c62 100644
--- a/go.mod
+++ b/go.mod
@@ -64,6 +64,8 @@ require (
cloud.google.com/go/longrunning v0.5.5 // indirect
cloud.google.com/go/storage v1.40.0 // indirect
github.com/MicahParks/keyfunc v1.9.0 // indirect
+ github.com/VirgilSecurity/virgil-crypto-go v0.0.0-20180221191626-33caf95f9a5d // indirect
+ github.com/VirgilSecurity/virgil-sdk-go v5.2.1+incompatible // indirect
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible // indirect
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
github.com/aws/aws-sdk-go-v2 v1.32.5 // indirect
@@ -253,6 +255,7 @@ require (
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
+ gopkg.in/virgil.v5 v5.2.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gorm.io/gorm v1.25.8 // indirect
k8s.io/api v0.31.2 // indirect
diff --git a/go.sum b/go.sum
index 025fedead..a84f11fdb 100644
--- a/go.sum
+++ b/go.sum
@@ -35,6 +35,10 @@ github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw=
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk=
github.com/QcloudApi/qcloud_sign_golang v0.0.0-20141224014652-e4130a326409/go.mod h1:1pk82RBxDY/JZnPQrtqHlUFfCctgdorsd9M06fMynOM=
+github.com/VirgilSecurity/virgil-crypto-go v0.0.0-20180221191626-33caf95f9a5d h1:ElVLTQRuo+LvdhsvybRwBTXvDCjMyB0Dv4mhOPnjQUQ=
+github.com/VirgilSecurity/virgil-crypto-go v0.0.0-20180221191626-33caf95f9a5d/go.mod h1:zyDDPi7Ihhd5JdTYQCcdmzACnF824PYV6E6UELQiZ1w=
+github.com/VirgilSecurity/virgil-sdk-go v5.2.1+incompatible h1:icWPcnsM0eqDs3pNxglM/3FbuF0Y9WUygpRM4PdBbec=
+github.com/VirgilSecurity/virgil-sdk-go v5.2.1+incompatible/go.mod h1:8kxwYsqg97YNwiVCrte1fqbP6H9VJ2vjSuyj1p1CP/8=
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible h1:8psS8a+wKfiLt1iVDX79F7Y6wUM49Lcha2FMXt4UM8g=
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8=
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
@@ -823,6 +827,8 @@ gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
+gopkg.in/virgil.v5 v5.2.1 h1:8NnvRXg66qC6C4uqVhuMEfm8wInUGC+QG2vdbMaCbUI=
+gopkg.in/virgil.v5 v5.2.1/go.mod h1:h9WN4Q+gzfXIJDa4kpGXzWo68Wuyte51oQejiVSNzwM=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
diff --git a/internal/api/crypto.go b/internal/api/crypto.go
new file mode 100644
index 000000000..26c11d0de
--- /dev/null
+++ b/internal/api/crypto.go
@@ -0,0 +1,47 @@
+package api
+
+import (
+ "github.com/gin-gonic/gin"
+ "github.com/openimsdk/protocol/crypto"
+ "github.com/openimsdk/tools/a2r"
+)
+
+type CryptoApi struct {
+ Client crypto.CryptoServiceClient
+}
+
+func NewCryptoApi(client crypto.CryptoServiceClient) CryptoApi {
+ return CryptoApi{Client: client}
+}
+
+func (o *CryptoApi) RegisterDevice(c *gin.Context) {
+ a2r.Call(c, crypto.CryptoServiceClient.RegisterDevice, o.Client)
+}
+
+func (o *CryptoApi) GetDevices(c *gin.Context) {
+ a2r.Call(c, crypto.CryptoServiceClient.GetDevices, o.Client)
+}
+
+func (o *CryptoApi) RevokeDevice(c *gin.Context) {
+ a2r.Call(c, crypto.CryptoServiceClient.RevokeDevice, o.Client)
+}
+
+func (o *CryptoApi) GetVirgilJWT(c *gin.Context) {
+ a2r.Call(c, crypto.CryptoServiceClient.GetVirgilJWT, o.Client)
+}
+
+func (o *CryptoApi) GetGroupKeyVersion(c *gin.Context) {
+ a2r.Call(c, crypto.CryptoServiceClient.GetGroupKeyVersion, o.Client)
+}
+
+func (o *CryptoApi) GetGroupKeyEvents(c *gin.Context) {
+ a2r.Call(c, crypto.CryptoServiceClient.GetGroupKeyEvents, o.Client)
+}
+
+func (o *CryptoApi) SecurityPrecheck(c *gin.Context) {
+ a2r.Call(c, crypto.CryptoServiceClient.SecurityPrecheck, o.Client)
+}
+
+func (o *CryptoApi) IntegrityReport(c *gin.Context) {
+ a2r.Call(c, crypto.CryptoServiceClient.IntegrityReport, o.Client)
+}
diff --git a/internal/api/router.go b/internal/api/router.go
index f689bec03..2e3cb02ad 100644
--- a/internal/api/router.go
+++ b/internal/api/router.go
@@ -12,6 +12,7 @@ import (
"github.com/openimsdk/protocol/group"
"github.com/openimsdk/protocol/msg"
"github.com/openimsdk/protocol/relation"
+ pbcrypto "github.com/openimsdk/protocol/crypto"
"github.com/openimsdk/protocol/rtc"
"github.com/openimsdk/protocol/third"
"github.com/openimsdk/protocol/user"
@@ -112,6 +113,10 @@ func newGinRouter(ctx context.Context, client discovery.SvcDiscoveryRegistry, co
if err != nil {
return nil, err
}
+ cryptoConn, err := client.GetConn(ctx, config.Share.RpcRegisterName.Crypto)
+ if err != nil {
+ return nil, err
+ }
gin.SetMode(gin.ReleaseMode)
r := gin.New()
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
@@ -344,6 +349,20 @@ func newGinRouter(ctx context.Context, client discovery.SvcDiscoveryRegistry, co
rtcGroup.POST("/delete_signal_records", rc.DeleteSignalRecords)
}
+ // Crypto / E2EE
+ {
+ cr := NewCryptoApi(pbcrypto.NewCryptoServiceClient(cryptoConn))
+ cryptoGroup := r.Group("/crypto")
+ cryptoGroup.POST("/register_device", cr.RegisterDevice)
+ cryptoGroup.POST("/get_devices", cr.GetDevices)
+ cryptoGroup.POST("/revoke_device", cr.RevokeDevice)
+ cryptoGroup.POST("/get_virgil_jwt", cr.GetVirgilJWT)
+ cryptoGroup.POST("/get_group_key_version", cr.GetGroupKeyVersion)
+ cryptoGroup.POST("/get_group_key_events", cr.GetGroupKeyEvents)
+ cryptoGroup.POST("/security_precheck", cr.SecurityPrecheck)
+ cryptoGroup.POST("/integrity_report", cr.IntegrityReport)
+ }
+
{
statisticsGroup := r.Group("/statistics")
statisticsGroup.POST("/user/register", u.UserRegisterCount)
diff --git a/internal/rpc/crypto/crypto.go b/internal/rpc/crypto/crypto.go
new file mode 100644
index 000000000..a1f168a58
--- /dev/null
+++ b/internal/rpc/crypto/crypto.go
@@ -0,0 +1,496 @@
+package crypto
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/VirgilSecurity/virgil-sdk-go/cryptoimpl"
+ "github.com/VirgilSecurity/virgil-sdk-go/sdk"
+ "github.com/openimsdk/open-im-server/v3/pkg/authverify"
+ "github.com/openimsdk/open-im-server/v3/pkg/common/config"
+ "github.com/openimsdk/open-im-server/v3/pkg/common/storage/controller"
+ "github.com/openimsdk/open-im-server/v3/pkg/common/storage/database/mgo"
+ "github.com/openimsdk/open-im-server/v3/pkg/common/storage/model"
+ pbcrypto "github.com/openimsdk/protocol/crypto"
+ "github.com/openimsdk/tools/db/mongoutil"
+ "github.com/openimsdk/tools/discovery"
+ "github.com/openimsdk/tools/errs"
+ "github.com/openimsdk/tools/log"
+ "github.com/openimsdk/tools/mcontext"
+ "google.golang.org/grpc"
+)
+
+const virgilJWTTTL = 20 * time.Minute
+
+type Config struct {
+ RpcConfig config.Crypto
+ MongodbConfig config.Mongo
+ Share config.Share
+ Discovery config.Discovery
+}
+
+type cryptoServer struct {
+ pbcrypto.UnimplementedCryptoServiceServer
+ config *Config
+ cryptoDB controller.CryptoDatabase
+ jwtGenerator *sdk.JwtGenerator
+}
+
+func Start(ctx context.Context, config *Config, _ discovery.SvcDiscoveryRegistry, server *grpc.Server) error {
+ mgocli, err := mongoutil.NewMongoDB(ctx, config.MongodbConfig.Build())
+ if err != nil {
+ return err
+ }
+ db := mgocli.GetDB()
+
+ deviceDB, err := mgo.NewCryptoDeviceMongo(db)
+ if err != nil {
+ return err
+ }
+ keyVersionDB, err := mgo.NewGroupKeyVersionMongo(db)
+ if err != nil {
+ return err
+ }
+ keyEventDB, err := mgo.NewGroupKeyEventMongo(db)
+ if err != nil {
+ return err
+ }
+
+ cryptoDB := controller.NewCryptoDatabase(deviceDB, keyVersionDB, keyEventDB, mgocli.GetTx())
+
+ var jwtGen *sdk.JwtGenerator
+ vc := config.RpcConfig.Virgil
+ if vc.AppID != "" && vc.AppKey != "" && vc.AppKeyID != "" {
+ virgilCrypto := cryptoimpl.NewVirgilCrypto()
+ privateKey, err := virgilCrypto.ImportPrivateKey([]byte(vc.AppKey), "")
+ if err != nil {
+ return fmt.Errorf("import virgil app key: %w", err)
+ }
+ jwtGen = sdk.NewJwtGenerator(
+ privateKey,
+ vc.AppKeyID,
+ cryptoimpl.NewVirgilAccessTokenSigner(),
+ vc.AppID,
+ virgilJWTTTL,
+ )
+ }
+
+ pbcrypto.RegisterCryptoServiceServer(server, &cryptoServer{
+ config: config,
+ cryptoDB: cryptoDB,
+ jwtGenerator: jwtGen,
+ })
+ return nil
+}
+
+func (s *cryptoServer) RegisterDevice(ctx context.Context, req *pbcrypto.RegisterDeviceReq) (*pbcrypto.RegisterDeviceResp, error) {
+ opUserID := mcontext.GetOpUserID(ctx)
+ log.ZDebug(ctx, "RegisterDevice request",
+ "opUserID", opUserID,
+ "targetUserID", req.UserID,
+ "deviceID", req.DeviceID,
+ "platform", req.Platform,
+ "deviceModel", req.DeviceModel,
+ "appVersion", req.AppVersion,
+ )
+ if req.UserID == "" || req.DeviceID == "" {
+ log.ZError(ctx, "RegisterDevice invalid args", errs.ErrArgs,
+ "opUserID", opUserID,
+ "targetUserID", req.UserID,
+ "deviceID", req.DeviceID,
+ )
+ return nil, errs.ErrArgs.WrapMsg("userID and deviceID are required")
+ }
+ if err := authverify.CheckAccessV3(ctx, req.UserID, s.config.Share.IMAdminUserID); err != nil {
+ log.ZError(ctx, "RegisterDevice auth check failed", err,
+ "opUserID", opUserID,
+ "targetUserID", req.UserID,
+ "deviceID", req.DeviceID,
+ )
+ return nil, err
+ }
+ device, err := s.cryptoDB.RegisterDevice(ctx, req.UserID, req.DeviceID, req.Platform, req.DeviceModel, req.AppVersion)
+ if err != nil {
+ log.ZError(ctx, "RegisterDevice db failed", err,
+ "opUserID", opUserID,
+ "targetUserID", req.UserID,
+ "deviceID", req.DeviceID,
+ "platform", req.Platform,
+ )
+ return nil, err
+ }
+ log.ZDebug(ctx, "RegisterDevice success",
+ "opUserID", opUserID,
+ "targetUserID", req.UserID,
+ "deviceID", req.DeviceID,
+ "virgilIdentity", device.VirgilIdentity,
+ )
+ return &pbcrypto.RegisterDeviceResp{
+ Device: modelDeviceToProto(device),
+ }, nil
+}
+
+func (s *cryptoServer) GetDevices(ctx context.Context, req *pbcrypto.GetDevicesReq) (*pbcrypto.GetDevicesResp, error) {
+ opUserID := mcontext.GetOpUserID(ctx)
+ log.ZDebug(ctx, "GetDevices request", "opUserID", opUserID, "targetUserID", req.UserID)
+ if req.UserID == "" {
+ log.ZError(ctx, "GetDevices invalid args", errs.ErrArgs, "opUserID", opUserID, "targetUserID", req.UserID)
+ return nil, errs.ErrArgs.WrapMsg("userID is required")
+ }
+ if err := authverify.CheckAccessV3(ctx, req.UserID, s.config.Share.IMAdminUserID); err != nil {
+ log.ZError(ctx, "GetDevices auth check failed", err, "opUserID", opUserID, "targetUserID", req.UserID)
+ return nil, err
+ }
+ devices, err := s.cryptoDB.GetDevices(ctx, req.UserID)
+ if err != nil {
+ log.ZError(ctx, "GetDevices db failed", err, "opUserID", opUserID, "targetUserID", req.UserID)
+ return nil, err
+ }
+ pbDevices := make([]*pbcrypto.DeviceInfo, 0, len(devices))
+ for _, d := range devices {
+ pbDevices = append(pbDevices, modelDeviceToProto(d))
+ }
+ log.ZDebug(ctx, "GetDevices success",
+ "opUserID", opUserID,
+ "targetUserID", req.UserID,
+ "deviceCount", len(devices),
+ )
+ return &pbcrypto.GetDevicesResp{Devices: pbDevices}, nil
+}
+
+func (s *cryptoServer) RevokeDevice(ctx context.Context, req *pbcrypto.RevokeDeviceReq) (*pbcrypto.RevokeDeviceResp, error) {
+ opUserID := mcontext.GetOpUserID(ctx)
+ log.ZDebug(ctx, "RevokeDevice request",
+ "opUserID", opUserID,
+ "targetUserID", req.UserID,
+ "deviceID", req.DeviceID,
+ )
+ if req.UserID == "" || req.DeviceID == "" {
+ log.ZError(ctx, "RevokeDevice invalid args", errs.ErrArgs,
+ "opUserID", opUserID,
+ "targetUserID", req.UserID,
+ "deviceID", req.DeviceID,
+ )
+ return nil, errs.ErrArgs.WrapMsg("userID and deviceID are required")
+ }
+ if err := authverify.CheckAccessV3(ctx, req.UserID, s.config.Share.IMAdminUserID); err != nil {
+ log.ZError(ctx, "RevokeDevice auth check failed", err,
+ "opUserID", opUserID,
+ "targetUserID", req.UserID,
+ "deviceID", req.DeviceID,
+ )
+ return nil, err
+ }
+ if err := s.cryptoDB.RevokeDevice(ctx, req.UserID, req.DeviceID); err != nil {
+ log.ZError(ctx, "RevokeDevice db failed", err,
+ "opUserID", opUserID,
+ "targetUserID", req.UserID,
+ "deviceID", req.DeviceID,
+ )
+ return nil, err
+ }
+ log.ZDebug(ctx, "RevokeDevice success",
+ "opUserID", opUserID,
+ "targetUserID", req.UserID,
+ "deviceID", req.DeviceID,
+ )
+ return &pbcrypto.RevokeDeviceResp{}, nil
+}
+
+func (s *cryptoServer) GetVirgilJWT(ctx context.Context, req *pbcrypto.GetVirgilJWTReq) (*pbcrypto.GetVirgilJWTResp, error) {
+ opUserID := mcontext.GetOpUserID(ctx)
+ log.ZDebug(ctx, "GetVirgilJWT request",
+ "opUserID", opUserID,
+ "targetUserID", req.UserID,
+ "deviceID", req.DeviceID,
+ )
+ if req.UserID == "" || req.DeviceID == "" {
+ log.ZError(ctx, "GetVirgilJWT invalid args", errs.ErrArgs,
+ "opUserID", opUserID,
+ "targetUserID", req.UserID,
+ "deviceID", req.DeviceID,
+ )
+ return nil, errs.ErrArgs.WrapMsg("userID and deviceID are required")
+ }
+ if err := authverify.CheckAccessV3(ctx, req.UserID, s.config.Share.IMAdminUserID); err != nil {
+ log.ZError(ctx, "GetVirgilJWT auth check failed", err,
+ "opUserID", opUserID,
+ "targetUserID", req.UserID,
+ "deviceID", req.DeviceID,
+ )
+ return nil, err
+ }
+ if s.jwtGenerator == nil {
+ log.ZError(ctx, "GetVirgilJWT jwt generator not configured", errs.New("virgil is not configured"),
+ "opUserID", opUserID,
+ "targetUserID", req.UserID,
+ "deviceID", req.DeviceID,
+ )
+ return nil, errs.New("virgil is not configured").Wrap()
+ }
+
+ device, err := s.cryptoDB.GetDevice(ctx, req.UserID, req.DeviceID)
+ if err != nil {
+ if errs.ErrRecordNotFound.Is(err) {
+ log.ZError(ctx, "GetVirgilJWT device not found", err,
+ "opUserID", opUserID,
+ "targetUserID", req.UserID,
+ "deviceID", req.DeviceID,
+ )
+ return nil, errs.ErrRecordNotFound.WrapMsg("device not found")
+ }
+ log.ZError(ctx, "GetVirgilJWT query device failed", err,
+ "opUserID", opUserID,
+ "targetUserID", req.UserID,
+ "deviceID", req.DeviceID,
+ )
+ return nil, err
+ }
+ if device.Status != "active" {
+ log.ZError(ctx, "GetVirgilJWT device revoked", errs.ErrNoPermission,
+ "opUserID", opUserID,
+ "targetUserID", req.UserID,
+ "deviceID", req.DeviceID,
+ "status", device.Status,
+ )
+ return nil, errs.ErrNoPermission.WrapMsg("device is revoked")
+ }
+
+ if err := s.cryptoDB.TouchDevice(ctx, req.UserID, req.DeviceID); err != nil {
+ log.ZError(ctx, "TouchDevice failed", err,
+ "opUserID", opUserID,
+ "userID", req.UserID,
+ "deviceID", req.DeviceID,
+ )
+ }
+
+ identity := req.UserID + ":" + req.DeviceID
+ token, err := s.jwtGenerator.GenerateToken(identity, nil)
+ if err != nil {
+ log.ZError(ctx, "GetVirgilJWT generate token failed", err,
+ "opUserID", opUserID,
+ "targetUserID", req.UserID,
+ "deviceID", req.DeviceID,
+ "virgilIdentity", identity,
+ )
+ return nil, errs.New("generate virgil jwt failed").Wrap()
+ }
+ log.ZDebug(ctx, "GetVirgilJWT success",
+ "opUserID", opUserID,
+ "targetUserID", req.UserID,
+ "deviceID", req.DeviceID,
+ "virgilIdentity", identity,
+ "expiresInSec", int64(virgilJWTTTL/time.Second),
+ )
+
+ return &pbcrypto.GetVirgilJWTResp{
+ VirgilJWT: token.String(),
+ ExpiresIn: int64(virgilJWTTTL / time.Second),
+ VirgilIdentity: identity,
+ }, nil
+}
+
+func (s *cryptoServer) GetGroupKeyVersion(ctx context.Context, req *pbcrypto.GetGroupKeyVersionReq) (*pbcrypto.GetGroupKeyVersionResp, error) {
+ opUserID := mcontext.GetOpUserID(ctx)
+ log.ZDebug(ctx, "GetGroupKeyVersion request", "opUserID", opUserID, "groupID", req.GroupID)
+ if req.GroupID == "" {
+ log.ZError(ctx, "GetGroupKeyVersion invalid args", errs.ErrArgs, "opUserID", opUserID, "groupID", req.GroupID)
+ return nil, errs.ErrArgs.WrapMsg("groupID is required")
+ }
+ version, err := s.cryptoDB.GetGroupKeyVersion(ctx, req.GroupID)
+ if err != nil {
+ log.ZError(ctx, "GetGroupKeyVersion db failed", err, "opUserID", opUserID, "groupID", req.GroupID)
+ return nil, err
+ }
+ log.ZDebug(ctx, "GetGroupKeyVersion success",
+ "opUserID", opUserID,
+ "groupID", req.GroupID,
+ "groupKeyVersion", version,
+ )
+ return &pbcrypto.GetGroupKeyVersionResp{
+ GroupID: req.GroupID,
+ GroupKeyVersion: version,
+ }, nil
+}
+
+// BumpGroupKeyVersion is internal-only (not exposed via HTTP API).
+// Called by Group Service after membership changes.
+func (s *cryptoServer) BumpGroupKeyVersion(ctx context.Context, req *pbcrypto.BumpGroupKeyVersionReq) (*pbcrypto.BumpGroupKeyVersionResp, error) {
+ opUserID := mcontext.GetOpUserID(ctx)
+ log.ZDebug(ctx, "BumpGroupKeyVersion request",
+ "opUserID", opUserID,
+ "groupID", req.GroupID,
+ "operatorUserID", req.OperatorUserID,
+ "eventType", req.EventType,
+ )
+ if req.GroupID == "" {
+ log.ZError(ctx, "BumpGroupKeyVersion invalid args", errs.ErrArgs,
+ "opUserID", opUserID,
+ "groupID", req.GroupID,
+ "operatorUserID", req.OperatorUserID,
+ "eventType", req.EventType,
+ )
+ return nil, errs.ErrArgs.WrapMsg("groupID is required")
+ }
+ newVersion, err := s.cryptoDB.BumpGroupKeyVersion(ctx, req.GroupID, req.OperatorUserID, req.EventType)
+ if err != nil {
+ log.ZError(ctx, "BumpGroupKeyVersion db failed", err,
+ "opUserID", opUserID,
+ "groupID", req.GroupID,
+ "operatorUserID", req.OperatorUserID,
+ "eventType", req.EventType,
+ )
+ return nil, err
+ }
+ log.ZDebug(ctx, "BumpGroupKeyVersion success",
+ "opUserID", opUserID,
+ "groupID", req.GroupID,
+ "operatorUserID", req.OperatorUserID,
+ "eventType", req.EventType,
+ "newGroupKeyVersion", newVersion,
+ )
+ return &pbcrypto.BumpGroupKeyVersionResp{
+ GroupID: req.GroupID,
+ GroupKeyVersion: newVersion,
+ }, nil
+}
+
+func (s *cryptoServer) GetGroupKeyEvents(ctx context.Context, req *pbcrypto.GetGroupKeyEventsReq) (*pbcrypto.GetGroupKeyEventsResp, error) {
+ opUserID := mcontext.GetOpUserID(ctx)
+ log.ZDebug(ctx, "GetGroupKeyEvents request",
+ "opUserID", opUserID,
+ "groupID", req.GroupID,
+ "sinceVersion", req.SinceVersion,
+ )
+ if req.GroupID == "" {
+ log.ZError(ctx, "GetGroupKeyEvents invalid args", errs.ErrArgs,
+ "opUserID", opUserID,
+ "groupID", req.GroupID,
+ "sinceVersion", req.SinceVersion,
+ )
+ return nil, errs.ErrArgs.WrapMsg("groupID is required")
+ }
+ events, err := s.cryptoDB.GetGroupKeyEvents(ctx, req.GroupID, req.SinceVersion)
+ if err != nil {
+ log.ZError(ctx, "GetGroupKeyEvents db failed", err,
+ "opUserID", opUserID,
+ "groupID", req.GroupID,
+ "sinceVersion", req.SinceVersion,
+ )
+ return nil, err
+ }
+ pbEvents := make([]*pbcrypto.GroupKeyEventInfo, 0, len(events))
+ for _, e := range events {
+ pbEvents = append(pbEvents, &pbcrypto.GroupKeyEventInfo{
+ EventID: e.EventID,
+ GroupID: e.GroupID,
+ GroupKeyVersion: e.GroupKeyVersion,
+ EventType: e.EventType,
+ OperatorUserID: e.OperatorUserID,
+ CreateTime: e.CreateTime.UnixMilli(),
+ })
+ }
+ log.ZDebug(ctx, "GetGroupKeyEvents success",
+ "opUserID", opUserID,
+ "groupID", req.GroupID,
+ "sinceVersion", req.SinceVersion,
+ "eventCount", len(events),
+ )
+ return &pbcrypto.GetGroupKeyEventsResp{Events: pbEvents}, nil
+}
+
+func (s *cryptoServer) SecurityPrecheck(ctx context.Context, req *pbcrypto.SecurityPrecheckReq) (*pbcrypto.SecurityPrecheckResp, error) {
+ opUserID := mcontext.GetOpUserID(ctx)
+ log.ZDebug(ctx, "SecurityPrecheck request",
+ "opUserID", opUserID,
+ "targetUserID", req.UserID,
+ "deviceID", req.DeviceID,
+ "action", req.Action,
+ )
+ if req.UserID == "" || req.DeviceID == "" {
+ log.ZError(ctx, "SecurityPrecheck invalid args", errs.ErrArgs,
+ "opUserID", opUserID,
+ "targetUserID", req.UserID,
+ "deviceID", req.DeviceID,
+ "action", req.Action,
+ )
+ return nil, errs.ErrArgs.WrapMsg("userID and deviceID are required")
+ }
+ if err := authverify.CheckAccessV3(ctx, req.UserID, s.config.Share.IMAdminUserID); err != nil {
+ log.ZError(ctx, "SecurityPrecheck auth check failed", err,
+ "opUserID", opUserID,
+ "targetUserID", req.UserID,
+ "deviceID", req.DeviceID,
+ "action", req.Action,
+ )
+ return nil, err
+ }
+ device, err := s.cryptoDB.GetDevice(ctx, req.UserID, req.DeviceID)
+ if err != nil {
+ log.ZDebug(ctx, "SecurityPrecheck denied",
+ "opUserID", opUserID,
+ "targetUserID", req.UserID,
+ "deviceID", req.DeviceID,
+ "reason", "device not found",
+ )
+ return &pbcrypto.SecurityPrecheckResp{Allowed: false, Reason: "device not found"}, nil
+ }
+ if device.Status != "active" {
+ log.ZDebug(ctx, "SecurityPrecheck denied",
+ "opUserID", opUserID,
+ "targetUserID", req.UserID,
+ "deviceID", req.DeviceID,
+ "reason", "device is revoked",
+ )
+ return &pbcrypto.SecurityPrecheckResp{Allowed: false, Reason: "device is revoked"}, nil
+ }
+ log.ZDebug(ctx, "SecurityPrecheck allowed",
+ "opUserID", opUserID,
+ "targetUserID", req.UserID,
+ "deviceID", req.DeviceID,
+ )
+ return &pbcrypto.SecurityPrecheckResp{Allowed: true}, nil
+}
+
+// IntegrityReport is a placeholder for future device integrity verification.
+// Currently accepts all reports; implement validation logic when ready.
+func (s *cryptoServer) IntegrityReport(ctx context.Context, req *pbcrypto.IntegrityReportReq) (*pbcrypto.IntegrityReportResp, error) {
+ opUserID := mcontext.GetOpUserID(ctx)
+ log.ZDebug(ctx, "IntegrityReport request",
+ "opUserID", opUserID,
+ "targetUserID", req.UserID,
+ "deviceID", req.DeviceID,
+ "timestamp", req.Timestamp,
+ "reportSize", len(req.ReportData),
+ )
+ if req.UserID == "" || req.DeviceID == "" {
+ log.ZError(ctx, "IntegrityReport invalid args", errs.ErrArgs,
+ "opUserID", opUserID,
+ "targetUserID", req.UserID,
+ "deviceID", req.DeviceID,
+ "timestamp", req.Timestamp,
+ )
+ return nil, errs.ErrArgs.WrapMsg("userID and deviceID are required")
+ }
+ log.ZDebug(ctx, "IntegrityReport accepted",
+ "opUserID", opUserID,
+ "targetUserID", req.UserID,
+ "deviceID", req.DeviceID,
+ )
+ return &pbcrypto.IntegrityReportResp{Accepted: true}, nil
+}
+
+func modelDeviceToProto(d *model.CryptoDevice) *pbcrypto.DeviceInfo {
+ return &pbcrypto.DeviceInfo{
+ DeviceID: d.DeviceID,
+ UserID: d.UserID,
+ Platform: d.Platform,
+ DeviceModel: d.DeviceModel,
+ AppVersion: d.AppVersion,
+ VirgilIdentity: d.VirgilIdentity,
+ Status: d.Status,
+ LastSeenAt: d.LastSeenAt.UnixMilli(),
+ CreateTime: d.CreateTime.UnixMilli(),
+ }
+}
diff --git a/internal/rpc/group/group.go b/internal/rpc/group/group.go
index acb816831..fe30b6c61 100644
--- a/internal/rpc/group/group.go
+++ b/internal/rpc/group/group.go
@@ -66,6 +66,7 @@ type groupServer struct {
userClient *rpcli.UserClient
msgClient *rpcli.MsgClient
conversationClient *rpcli.ConversationClient
+ cryptoClient *rpcli.CryptoClient
}
type Config struct {
@@ -117,12 +118,17 @@ func Start(ctx context.Context, config *Config, client discovery.SvcDiscoveryReg
if err != nil {
return err
}
+ cryptoConn, err := client.GetConn(ctx, config.Share.RpcRegisterName.Crypto)
+ if err != nil {
+ return err
+ }
gs := groupServer{
config: config,
webhookClient: webhook.NewWebhookClient(config.WebhooksConfig.URL),
userClient: rpcli.NewUserClient(userConn),
msgClient: rpcli.NewMsgClient(msgConn),
conversationClient: rpcli.NewConversationClient(conversationConn),
+ cryptoClient: rpcli.NewCryptoClient(cryptoConn),
}
gs.db = controller.NewGroupDatabase(rdb, &config.LocalCacheConfig, groupDB, groupMemberDB, groupRequestDB, mgocli.GetTx(), grouphash.NewGroupHashFromGroupServer(&gs))
gs.notification = NewNotificationSender(gs.db, config, gs.userClient, gs.msgClient, gs.conversationClient)
@@ -274,6 +280,7 @@ func (s *groupServer) CreateGroup(ctx context.Context, req *pbgroup.CreateGroupR
if err := s.db.CreateGroup(ctx, []*model.Group{group}, groupMembers); err != nil {
return nil, err
}
+ s.cryptoClient.InitGroupKeyVersion(ctx, group.GroupID)
resp := &pbgroup.CreateGroupResp{GroupInfo: &sdkws.GroupInfo{}}
resp.GroupInfo = convert.Db2PbGroupInfo(group, req.OwnerUserID, uint32(len(userIDs)))
@@ -544,6 +551,7 @@ func (s *groupServer) InviteUserToGroup(ctx context.Context, req *pbgroup.Invite
return nil, err
}
}
+ s.cryptoClient.BumpGroupKeyVersion(ctx, req.GroupID, opUserID, "member_added")
return &pbgroup.InviteUserToGroupResp{}, nil
}
@@ -709,6 +717,7 @@ func (s *groupServer) KickGroupMember(ctx context.Context, req *pbgroup.KickGrou
return nil, err
}
s.webhookAfterKickGroupMember(ctx, &s.config.WebhooksConfig.AfterKickGroupMember, req)
+ s.cryptoClient.BumpGroupKeyVersion(ctx, req.GroupID, opUserID, "member_removed")
return &pbgroup.KickGroupMemberResp{}, nil
}
@@ -964,6 +973,7 @@ func (s *groupServer) GroupApplicationResponse(ctx context.Context, req *pbgroup
if err := s.setMemberJoinSeq(ctx, req.GroupID, []string{req.FromUserID}); err != nil {
return nil, err
}
+ s.cryptoClient.BumpGroupKeyVersion(ctx, req.GroupID, mcontext.GetOpUserID(ctx), "member_added")
}
case constant.GroupResponseRefuse:
s.notification.GroupApplicationRejectedNotification(ctx, req)
@@ -1029,6 +1039,7 @@ func (s *groupServer) JoinGroup(ctx context.Context, req *pbgroup.JoinGroupReq)
if err := s.setMemberJoinSeq(ctx, req.GroupID, []string{req.InviterUserID}); err != nil {
return nil, err
}
+ s.cryptoClient.BumpGroupKeyVersion(ctx, req.GroupID, req.InviterUserID, "member_added")
s.webhookAfterJoinGroup(ctx, &s.config.WebhooksConfig.AfterJoinGroup, req)
return &pbgroup.JoinGroupResp{}, nil
@@ -1077,6 +1088,7 @@ func (s *groupServer) QuitGroup(ctx context.Context, req *pbgroup.QuitGroupReq)
return nil, err
}
s.webhookAfterQuitGroup(ctx, &s.config.WebhooksConfig.AfterQuitGroup, req)
+ s.cryptoClient.BumpGroupKeyVersion(ctx, req.GroupID, req.UserID, "member_left")
return &pbgroup.QuitGroupResp{}, nil
}
@@ -1563,6 +1575,7 @@ func (s *groupServer) DismissGroup(ctx context.Context, req *pbgroup.DismissGrou
}
s.webhookAfterDismissGroup(ctx, &s.config.WebhooksConfig.AfterDismissGroup, cbReq)
+ s.cryptoClient.BumpGroupKeyVersion(ctx, req.GroupID, mcontext.GetOpUserID(ctx), "group_dismissed")
return &pbgroup.DismissGroupResp{}, nil
}
diff --git a/pkg/common/cmd/constant.go b/pkg/common/cmd/constant.go
index c59e4dd50..dd770f688 100644
--- a/pkg/common/cmd/constant.go
+++ b/pkg/common/cmd/constant.go
@@ -44,6 +44,7 @@ var (
OpenIMRPCThirdCfgFileName string
OpenIMRPCUserCfgFileName string
OpenIMRPCRtcCfgFileName string
+ OpenIMRPCCryptoCfgFileName string
DiscoveryConfigFilename string
)
@@ -75,6 +76,7 @@ func init() {
OpenIMRPCThirdCfgFileName = "openim-rpc-third.yml"
OpenIMRPCUserCfgFileName = "openim-rpc-user.yml"
OpenIMRPCRtcCfgFileName = "openim-rpc-rtc.yml"
+ OpenIMRPCCryptoCfgFileName = "openim-rpc-crypto.yml"
DiscoveryConfigFilename = "discovery.yml"
ConfigEnvPrefixMap = make(map[string]string)
@@ -85,7 +87,7 @@ func init() {
OpenIMAPICfgFileName, OpenIMCronTaskCfgFileName, OpenIMMsgGatewayCfgFileName,
OpenIMMsgTransferCfgFileName, OpenIMPushCfgFileName, OpenIMCaptchaCfgFileName, OpenIMRPCAuthCfgFileName, OpenIMRPCCaptchaCfgFileName,
OpenIMRPCConversationCfgFileName, OpenIMRPCFriendCfgFileName, OpenIMRPCGroupCfgFileName,
- OpenIMRPCMsgCfgFileName, OpenIMRPCThirdCfgFileName, OpenIMRPCUserCfgFileName, OpenIMRPCRtcCfgFileName, DiscoveryConfigFilename,
+ OpenIMRPCMsgCfgFileName, OpenIMRPCThirdCfgFileName, OpenIMRPCUserCfgFileName, OpenIMRPCRtcCfgFileName, OpenIMRPCCryptoCfgFileName, DiscoveryConfigFilename,
}
for _, fileName := range fileNames {
diff --git a/pkg/common/cmd/rpc_crypto.go b/pkg/common/cmd/rpc_crypto.go
new file mode 100644
index 000000000..ab37ceeb8
--- /dev/null
+++ b/pkg/common/cmd/rpc_crypto.go
@@ -0,0 +1,47 @@
+package cmd
+
+import (
+ "context"
+
+ "github.com/openimsdk/open-im-server/v3/internal/rpc/crypto"
+ "github.com/openimsdk/open-im-server/v3/pkg/common/startrpc"
+ "github.com/openimsdk/open-im-server/v3/version"
+ "github.com/openimsdk/tools/system/program"
+ "github.com/spf13/cobra"
+)
+
+type CryptoRpcCmd struct {
+ *RootCmd
+ ctx context.Context
+ configMap map[string]any
+ cryptoConfig *crypto.Config
+}
+
+func NewCryptoRpcCmd() *CryptoRpcCmd {
+ var cryptoConfig crypto.Config
+ ret := &CryptoRpcCmd{cryptoConfig: &cryptoConfig}
+ ret.configMap = map[string]any{
+ OpenIMRPCCryptoCfgFileName: &cryptoConfig.RpcConfig,
+ MongodbConfigFileName: &cryptoConfig.MongodbConfig,
+ ShareFileName: &cryptoConfig.Share,
+ DiscoveryConfigFilename: &cryptoConfig.Discovery,
+ }
+ ret.RootCmd = NewRootCmd(program.GetProcessName(), WithConfigMap(ret.configMap))
+ ret.ctx = context.WithValue(context.Background(), "version", version.Version)
+ ret.Command.RunE = func(cmd *cobra.Command, args []string) error {
+ return ret.runE()
+ }
+ return ret
+}
+
+func (c *CryptoRpcCmd) Exec() error {
+ return c.Execute()
+}
+
+func (c *CryptoRpcCmd) runE() error {
+ return startrpc.Start(c.ctx, &c.cryptoConfig.Discovery, &c.cryptoConfig.RpcConfig.Prometheus, c.cryptoConfig.RpcConfig.RPC.ListenIP,
+ c.cryptoConfig.RpcConfig.RPC.RegisterIP, c.cryptoConfig.RpcConfig.RPC.AutoSetPorts, c.cryptoConfig.RpcConfig.RPC.Ports,
+ c.Index(), c.cryptoConfig.Share.RpcRegisterName.Crypto, &c.cryptoConfig.Share, c.cryptoConfig,
+ nil,
+ crypto.Start)
+}
diff --git a/pkg/common/config/config.go b/pkg/common/config/config.go
index 05616abea..52ddb7cac 100644
--- a/pkg/common/config/config.go
+++ b/pkg/common/config/config.go
@@ -426,6 +426,7 @@ type RpcRegisterName struct {
Third string `mapstructure:"third"`
Captcha string `mapstructure:"captcha"`
Rtc string `mapstructure:"rtc"`
+ Crypto string `mapstructure:"crypto"`
}
func (r *RpcRegisterName) GetServiceNames() []string {
@@ -441,6 +442,7 @@ func (r *RpcRegisterName) GetServiceNames() []string {
r.Third,
r.Captcha,
r.Rtc,
+ r.Crypto,
}
}
@@ -463,6 +465,23 @@ type Rtc struct {
LiveKit LiveKit `mapstructure:"liveKit"`
}
+type Crypto struct {
+ RPC struct {
+ RegisterIP string `mapstructure:"registerIP"`
+ ListenIP string `mapstructure:"listenIP"`
+ AutoSetPorts bool `mapstructure:"autoSetPorts"`
+ Ports []int `mapstructure:"ports"`
+ } `mapstructure:"rpc"`
+ Prometheus Prometheus `mapstructure:"prometheus"`
+ Virgil VirgilConfig `mapstructure:"virgil"`
+}
+
+type VirgilConfig struct {
+ AppID string `mapstructure:"appID"`
+ AppKey string `mapstructure:"appKey"`
+ AppKeyID string `mapstructure:"appKeyID"`
+}
+
// FullConfig stores all configurations for before and after events
type Webhooks struct {
@@ -674,6 +693,7 @@ var (
OpenIMRPCThirdCfgFileName = "openim-rpc-third.yml"
OpenIMRPCUserCfgFileName = "openim-rpc-user.yml"
OpenIMRPCRtcCfgFileName = "openim-rpc-rtc.yml"
+ OpenIMRPCCryptoCfgFileName = "openim-rpc-crypto.yml"
RedisConfigFileName = "redis.yml"
ShareFileName = "share.yml"
WebhooksConfigFileName = "webhooks.yml"
@@ -763,6 +783,10 @@ func (r *Rtc) GetConfigFileName() string {
return OpenIMRPCRtcCfgFileName
}
+func (c *Crypto) GetConfigFileName() string {
+ return OpenIMRPCCryptoCfgFileName
+}
+
func (r *Redis) GetConfigFileName() string {
return RedisConfigFileName
}
diff --git a/pkg/common/storage/controller/crypto.go b/pkg/common/storage/controller/crypto.go
new file mode 100644
index 000000000..5822faebe
--- /dev/null
+++ b/pkg/common/storage/controller/crypto.go
@@ -0,0 +1,133 @@
+package controller
+
+import (
+ "context"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/openimsdk/open-im-server/v3/pkg/common/storage/database"
+ "github.com/openimsdk/open-im-server/v3/pkg/common/storage/model"
+ "github.com/openimsdk/tools/db/tx"
+ "github.com/openimsdk/tools/log"
+)
+
+type CryptoDatabase interface {
+ RegisterDevice(ctx context.Context, userID, deviceID, platform, deviceModel, appVersion string) (*model.CryptoDevice, error)
+ GetDevices(ctx context.Context, userID string) ([]*model.CryptoDevice, error)
+ GetDevice(ctx context.Context, userID, deviceID string) (*model.CryptoDevice, error)
+ RevokeDevice(ctx context.Context, userID, deviceID string) error
+ TouchDevice(ctx context.Context, userID, deviceID string) error
+
+ GetGroupKeyVersion(ctx context.Context, groupID string) (int64, error)
+ BumpGroupKeyVersion(ctx context.Context, groupID, operatorUserID, eventType string) (int64, error)
+ GetGroupKeyEvents(ctx context.Context, groupID string, sinceVersion int64) ([]*model.GroupKeyEvent, error)
+}
+
+type cryptoDatabase struct {
+ deviceDB database.CryptoDevice
+ keyVersionDB database.GroupKeyVersion
+ keyEventDB database.GroupKeyEvent
+ tx tx.Tx
+}
+
+func NewCryptoDatabase(
+ deviceDB database.CryptoDevice,
+ keyVersionDB database.GroupKeyVersion,
+ keyEventDB database.GroupKeyEvent,
+ tx tx.Tx,
+) CryptoDatabase {
+ return &cryptoDatabase{
+ deviceDB: deviceDB,
+ keyVersionDB: keyVersionDB,
+ keyEventDB: keyEventDB,
+ tx: tx,
+ }
+}
+
+func (c *cryptoDatabase) RegisterDevice(ctx context.Context, userID, deviceID, platform, deviceModel, appVersion string) (*model.CryptoDevice, error) {
+ virgilIdentity := userID + ":" + deviceID
+ now := time.Now()
+ device := &model.CryptoDevice{
+ DeviceID: deviceID,
+ UserID: userID,
+ Platform: platform,
+ DeviceModel: deviceModel,
+ AppVersion: appVersion,
+ VirgilIdentity: virgilIdentity,
+ Status: "active",
+ LastSeenAt: now,
+ CreateTime: now,
+ }
+ if err := c.deviceDB.Create(ctx, device); err != nil {
+ return nil, err
+ }
+ return device, nil
+}
+
+func (c *cryptoDatabase) GetDevices(ctx context.Context, userID string) ([]*model.CryptoDevice, error) {
+ return c.deviceDB.FindByUserID(ctx, userID)
+}
+
+func (c *cryptoDatabase) GetDevice(ctx context.Context, userID, deviceID string) (*model.CryptoDevice, error) {
+ return c.deviceDB.FindByUserIDAndDeviceID(ctx, userID, deviceID)
+}
+
+func (c *cryptoDatabase) RevokeDevice(ctx context.Context, userID, deviceID string) error {
+ return c.deviceDB.UpdateStatus(ctx, userID, deviceID, "revoked")
+}
+
+func (c *cryptoDatabase) TouchDevice(ctx context.Context, userID, deviceID string) error {
+ return c.deviceDB.UpdateLastSeen(ctx, userID, deviceID)
+}
+
+func (c *cryptoDatabase) GetGroupKeyVersion(ctx context.Context, groupID string) (int64, error) {
+ v, err := c.keyVersionDB.Find(ctx, groupID)
+ if err != nil {
+ return 0, err
+ }
+ return v.GroupKeyVersion, nil
+}
+
+func (c *cryptoDatabase) BumpGroupKeyVersion(ctx context.Context, groupID, operatorUserID, eventType string) (int64, error) {
+ log.ZDebug(ctx, "cryptoDatabase BumpGroupKeyVersion begin",
+ "groupID", groupID,
+ "operatorUserID", operatorUserID,
+ "eventType", eventType,
+ )
+ var newVersion int64
+ err := c.tx.Transaction(ctx, func(ctx context.Context) error {
+ var err error
+ newVersion, err = c.keyVersionDB.IncrVersion(ctx, groupID)
+ if err != nil {
+ return err
+ }
+ event := &model.GroupKeyEvent{
+ EventID: uuid.New().String(),
+ GroupID: groupID,
+ GroupKeyVersion: newVersion,
+ EventType: eventType,
+ OperatorUserID: operatorUserID,
+ CreateTime: time.Now(),
+ }
+ return c.keyEventDB.Create(ctx, event)
+ })
+ if err != nil {
+ log.ZError(ctx, "cryptoDatabase BumpGroupKeyVersion failed", err,
+ "groupID", groupID,
+ "operatorUserID", operatorUserID,
+ "eventType", eventType,
+ )
+ return 0, err
+ }
+ log.ZDebug(ctx, "cryptoDatabase BumpGroupKeyVersion success",
+ "groupID", groupID,
+ "operatorUserID", operatorUserID,
+ "eventType", eventType,
+ "newGroupKeyVersion", newVersion,
+ )
+ return newVersion, nil
+}
+
+func (c *cryptoDatabase) GetGroupKeyEvents(ctx context.Context, groupID string, sinceVersion int64) ([]*model.GroupKeyEvent, error) {
+ return c.keyEventDB.FindSinceVersion(ctx, groupID, sinceVersion)
+}
diff --git a/pkg/common/storage/database/crypto.go b/pkg/common/storage/database/crypto.go
new file mode 100644
index 000000000..b63ef7d69
--- /dev/null
+++ b/pkg/common/storage/database/crypto.go
@@ -0,0 +1,25 @@
+package database
+
+import (
+ "context"
+
+ "github.com/openimsdk/open-im-server/v3/pkg/common/storage/model"
+)
+
+type CryptoDevice interface {
+ Create(ctx context.Context, device *model.CryptoDevice) error
+ FindByUserID(ctx context.Context, userID string) ([]*model.CryptoDevice, error)
+ FindByUserIDAndDeviceID(ctx context.Context, userID, deviceID string) (*model.CryptoDevice, error)
+ UpdateStatus(ctx context.Context, userID, deviceID, status string) error
+ UpdateLastSeen(ctx context.Context, userID, deviceID string) error
+}
+
+type GroupKeyVersion interface {
+ Find(ctx context.Context, groupID string) (*model.GroupKeyVersion, error)
+ IncrVersion(ctx context.Context, groupID string) (int64, error)
+}
+
+type GroupKeyEvent interface {
+ Create(ctx context.Context, event *model.GroupKeyEvent) error
+ FindSinceVersion(ctx context.Context, groupID string, sinceVersion int64) ([]*model.GroupKeyEvent, error)
+}
diff --git a/pkg/common/storage/database/mgo/crypto.go b/pkg/common/storage/database/mgo/crypto.go
new file mode 100644
index 000000000..8e7bc9779
--- /dev/null
+++ b/pkg/common/storage/database/mgo/crypto.go
@@ -0,0 +1,183 @@
+package mgo
+
+import (
+ "context"
+ "time"
+
+ "github.com/openimsdk/open-im-server/v3/pkg/common/storage/database"
+ "github.com/openimsdk/open-im-server/v3/pkg/common/storage/model"
+ "github.com/openimsdk/tools/errs"
+ "go.mongodb.org/mongo-driver/bson"
+ "go.mongodb.org/mongo-driver/mongo"
+ "go.mongodb.org/mongo-driver/mongo/options"
+)
+
+// ---- CryptoDevice ----
+
+type CryptoDeviceMgo struct {
+ coll *mongo.Collection
+}
+
+func NewCryptoDeviceMongo(db *mongo.Database) (database.CryptoDevice, error) {
+ coll := db.Collection("crypto_device")
+ _, err := coll.Indexes().CreateMany(context.Background(), []mongo.IndexModel{
+ {
+ Keys: bson.D{{Key: "user_id", Value: 1}, {Key: "device_id", Value: 1}},
+ Options: options.Index().SetUnique(true),
+ },
+ {
+ Keys: bson.D{{Key: "user_id", Value: 1}},
+ },
+ })
+ if err != nil {
+ return nil, err
+ }
+ return &CryptoDeviceMgo{coll: coll}, nil
+}
+
+func (m *CryptoDeviceMgo) Create(ctx context.Context, device *model.CryptoDevice) error {
+ _, err := m.coll.InsertOne(ctx, device)
+ return err
+}
+
+func (m *CryptoDeviceMgo) FindByUserID(ctx context.Context, userID string) ([]*model.CryptoDevice, error) {
+ cursor, err := m.coll.Find(ctx, bson.M{"user_id": userID})
+ if err != nil {
+ return nil, err
+ }
+ var devices []*model.CryptoDevice
+ if err := cursor.All(ctx, &devices); err != nil {
+ return nil, err
+ }
+ return devices, nil
+}
+
+func (m *CryptoDeviceMgo) FindByUserIDAndDeviceID(ctx context.Context, userID, deviceID string) (*model.CryptoDevice, error) {
+ var device model.CryptoDevice
+ err := m.coll.FindOne(ctx, bson.M{"user_id": userID, "device_id": deviceID}).Decode(&device)
+ if err != nil {
+ if err == mongo.ErrNoDocuments {
+ return nil, errs.ErrRecordNotFound.WrapMsg("crypto device not found", "userID", userID, "deviceID", deviceID)
+ }
+ return nil, err
+ }
+ return &device, nil
+}
+
+func (m *CryptoDeviceMgo) UpdateStatus(ctx context.Context, userID, deviceID, status string) error {
+ result, err := m.coll.UpdateOne(ctx,
+ bson.M{"user_id": userID, "device_id": deviceID},
+ bson.M{"$set": bson.M{"status": status}},
+ )
+ if err != nil {
+ return err
+ }
+ if result.MatchedCount == 0 {
+ return errs.ErrRecordNotFound.WrapMsg("crypto device not found", "userID", userID, "deviceID", deviceID)
+ }
+ return nil
+}
+
+func (m *CryptoDeviceMgo) UpdateLastSeen(ctx context.Context, userID, deviceID string) error {
+ result, err := m.coll.UpdateOne(ctx,
+ bson.M{"user_id": userID, "device_id": deviceID},
+ bson.M{"$set": bson.M{"last_seen_at": time.Now()}},
+ )
+ if err != nil {
+ return err
+ }
+ if result.MatchedCount == 0 {
+ return errs.ErrRecordNotFound.WrapMsg("crypto device not found", "userID", userID, "deviceID", deviceID)
+ }
+ return nil
+}
+
+// ---- GroupKeyVersion ----
+
+type GroupKeyVersionMgo struct {
+ coll *mongo.Collection
+}
+
+func NewGroupKeyVersionMongo(db *mongo.Database) (database.GroupKeyVersion, error) {
+ coll := db.Collection("group_key_version")
+ _, err := coll.Indexes().CreateOne(context.Background(), mongo.IndexModel{
+ Keys: bson.D{{Key: "group_id", Value: 1}},
+ Options: options.Index().SetUnique(true),
+ })
+ if err != nil {
+ return nil, err
+ }
+ return &GroupKeyVersionMgo{coll: coll}, nil
+}
+
+func (m *GroupKeyVersionMgo) Find(ctx context.Context, groupID string) (*model.GroupKeyVersion, error) {
+ var v model.GroupKeyVersion
+ err := m.coll.FindOne(ctx, bson.M{"group_id": groupID}).Decode(&v)
+ if err != nil {
+ if err == mongo.ErrNoDocuments {
+ return nil, errs.ErrRecordNotFound.WrapMsg("group key version not found", "groupID", groupID)
+ }
+ return nil, err
+ }
+ return &v, nil
+}
+
+func (m *GroupKeyVersionMgo) IncrVersion(ctx context.Context, groupID string) (int64, error) {
+ var result model.GroupKeyVersion
+ err := m.coll.FindOneAndUpdate(ctx,
+ bson.M{"group_id": groupID},
+ bson.M{"$inc": bson.M{"group_key_version": int64(1)}},
+ options.FindOneAndUpdate().SetUpsert(true).SetReturnDocument(options.After),
+ ).Decode(&result)
+ if err != nil {
+ return 0, err
+ }
+ return result.GroupKeyVersion, nil
+}
+
+// ---- GroupKeyEvent ----
+
+type GroupKeyEventMgo struct {
+ coll *mongo.Collection
+}
+
+const maxGroupKeyEventsPerQuery = 500
+
+func NewGroupKeyEventMongo(db *mongo.Database) (database.GroupKeyEvent, error) {
+ coll := db.Collection("group_key_event")
+ _, err := coll.Indexes().CreateMany(context.Background(), []mongo.IndexModel{
+ {
+ Keys: bson.D{{Key: "group_id", Value: 1}, {Key: "group_key_version", Value: 1}},
+ },
+ {
+ Keys: bson.D{{Key: "event_id", Value: 1}},
+ Options: options.Index().SetUnique(true),
+ },
+ })
+ if err != nil {
+ return nil, err
+ }
+ return &GroupKeyEventMgo{coll: coll}, nil
+}
+
+func (m *GroupKeyEventMgo) Create(ctx context.Context, event *model.GroupKeyEvent) error {
+ _, err := m.coll.InsertOne(ctx, event)
+ return err
+}
+
+func (m *GroupKeyEventMgo) FindSinceVersion(ctx context.Context, groupID string, sinceVersion int64) ([]*model.GroupKeyEvent, error) {
+ cursor, err := m.coll.Find(ctx, bson.M{
+ "group_id": groupID,
+ "group_key_version": bson.M{"$gt": sinceVersion},
+ }, options.Find().
+ SetSort(bson.D{{Key: "group_key_version", Value: 1}}).
+ SetLimit(maxGroupKeyEventsPerQuery))
+ if err != nil {
+ return nil, err
+ }
+ var events []*model.GroupKeyEvent
+ if err := cursor.All(ctx, &events); err != nil {
+ return nil, err
+ }
+ return events, nil
+}
diff --git a/pkg/common/storage/model/crypto.go b/pkg/common/storage/model/crypto.go
new file mode 100644
index 000000000..24328a317
--- /dev/null
+++ b/pkg/common/storage/model/crypto.go
@@ -0,0 +1,29 @@
+package model
+
+import "time"
+
+type CryptoDevice struct {
+ DeviceID string `bson:"device_id"`
+ UserID string `bson:"user_id"`
+ Platform string `bson:"platform"`
+ DeviceModel string `bson:"device_model"`
+ AppVersion string `bson:"app_version"`
+ VirgilIdentity string `bson:"virgil_identity"`
+ Status string `bson:"status"`
+ LastSeenAt time.Time `bson:"last_seen_at"`
+ CreateTime time.Time `bson:"create_time"`
+}
+
+type GroupKeyVersion struct {
+ GroupID string `bson:"group_id"`
+ GroupKeyVersion int64 `bson:"group_key_version"`
+}
+
+type GroupKeyEvent struct {
+ EventID string `bson:"event_id"`
+ GroupID string `bson:"group_id"`
+ GroupKeyVersion int64 `bson:"group_key_version"`
+ EventType string `bson:"event_type"`
+ OperatorUserID string `bson:"operator_user_id"`
+ CreateTime time.Time `bson:"create_time"`
+}
diff --git a/pkg/rpcli/crypto.go b/pkg/rpcli/crypto.go
new file mode 100644
index 000000000..7b7229a95
--- /dev/null
+++ b/pkg/rpcli/crypto.go
@@ -0,0 +1,51 @@
+package rpcli
+
+import (
+ "context"
+
+ pbcrypto "github.com/openimsdk/protocol/crypto"
+ "github.com/openimsdk/tools/log"
+ "google.golang.org/grpc"
+)
+
+func NewCryptoClient(cc grpc.ClientConnInterface) *CryptoClient {
+ return &CryptoClient{pbcrypto.NewCryptoServiceClient(cc)}
+}
+
+type CryptoClient struct {
+ pbcrypto.CryptoServiceClient
+}
+
+func (x *CryptoClient) BumpGroupKeyVersion(ctx context.Context, groupID, operatorUserID, eventType string) {
+ log.ZDebug(ctx, "BumpGroupKeyVersion start", "groupID", groupID, "operatorUserID", operatorUserID, "eventType", eventType)
+ resp, err := x.CryptoServiceClient.BumpGroupKeyVersion(ctx, &pbcrypto.BumpGroupKeyVersionReq{
+ GroupID: groupID,
+ OperatorUserID: operatorUserID,
+ EventType: eventType,
+ })
+ if err != nil {
+ log.ZError(ctx, "BumpGroupKeyVersion failed", err,
+ "groupID", groupID,
+ "operatorUserID", operatorUserID,
+ "eventType", eventType,
+ )
+ return
+ }
+ log.ZDebug(ctx, "BumpGroupKeyVersion success", "groupID", groupID, "newVersion", resp.GroupKeyVersion)
+}
+
+func (x *CryptoClient) InitGroupKeyVersion(ctx context.Context, groupID string) {
+ log.ZDebug(ctx, "InitGroupKeyVersion start", "groupID", groupID, "eventType", "group_created")
+ _, err := x.CryptoServiceClient.BumpGroupKeyVersion(ctx, &pbcrypto.BumpGroupKeyVersionReq{
+ GroupID: groupID,
+ EventType: "group_created",
+ })
+ if err != nil {
+ log.ZError(ctx, "InitGroupKeyVersion failed", err,
+ "groupID", groupID,
+ "eventType", "group_created",
+ )
+ return
+ }
+ log.ZDebug(ctx, "InitGroupKeyVersion success", "groupID", groupID)
+}
diff --git a/virgil_chat_server_design.md b/virgil_chat_server_design.md
new file mode 100644
index 000000000..793ce66a8
--- /dev/null
+++ b/virgil_chat_server_design.md
@@ -0,0 +1,675 @@
+# Virgil Security 接入到聊天系统设计方案(服务器)
+
+## 1. 服务器设计目标
+
+服务端的目标不是参与解密,而是提供 **业务承载层**:
+
+- 业务身份认证
+- 设备管理
+- Virgil JWT 签发
+- 单聊/群聊会话管理
+- 密文消息接入与路由
+- 离线同步
+- 文件上传/下载票据
+- 群成员关系与群密钥版本索引
+- 风控与设备吊销
+
+---
+
+## 2. 服务器总体架构
+
+```mermaid
+flowchart LR
+ subgraph Backend["业务服务端"]
+ B1["API Gateway"]
+ B2["Auth Service"]
+ B3["Virgil JWT Service"]
+ B4["Conversation Service"]
+ B5["Message Router / Sync"]
+ B6["Group Service"]
+ B7["Push Service"]
+ B8["File Ticket Service"]
+ B9["Risk / Device Service"]
+ B10["Metadata DB"]
+ end
+
+ subgraph Virgil["Virgil Cloud"]
+ V1["Cards / Public Key Directory"]
+ V2["Restore Related Services"]
+ end
+
+ subgraph OSS["Object Storage / CDN"]
+ O1["Encrypted File Objects"]
+ end
+
+ B1 --> B2
+ B1 --> B3
+ B1 --> B4
+ B1 --> B5
+ B1 --> B6
+ B1 --> B8
+ B1 --> B9
+
+ B2 --> B10
+ B3 --> B10
+ B4 --> B10
+ B5 --> B10
+ B6 --> B10
+ B8 --> O1
+ B9 --> B10
+
+ B3 --> V1
+ B3 --> V2
+```
+
+---
+
+## 3. 服务器职责边界
+
+## 3.1 服务端应该负责什么
+
+- 用户登录与 token
+- 设备注册与吊销
+- Virgil JWT 签发
+- 会话与群组元数据
+- 密文消息存储
+- 消息序列号与游标
+- 离线消息投递
+- 已读/已送达状态
+- 文件票据
+- 群成员变更事件
+- 风控控制
+
+## 3.2 服务端不应该负责什么
+
+- 私钥保存
+- 文本明文解析
+- 文件明文解析
+- 群密钥管理的明文操作
+- 替客户端做消息加解密
+
+---
+
+## 4. 推荐服务拆分
+
+```text
+api-gateway
+auth-service
+virgil-jwt-service
+conversation-service
+message-service
+group-service
+sync-service
+push-service
+file-ticket-service
+risk-device-service
+```
+
+### 各服务说明
+
+#### api-gateway
+- 统一入口
+- 鉴权
+- 请求限流
+- trace 注入
+
+#### auth-service
+- 登录
+- refresh
+- logout
+
+#### virgil-jwt-service
+- 生成 Virgil JWT
+- user/device 到 virgil identity 的映射
+- 风控前置校验
+
+#### conversation-service
+- 单聊会话
+- 会话设置
+- 会话列表
+
+#### message-service
+- 接收密文 envelope
+- 分配 message_id / seq
+- 存储与 fanout
+
+#### group-service
+- 创建群
+- 加人/踢人/退群
+- 群资料
+- 维护 group_key_version
+
+#### sync-service
+- bootstrap
+- 增量消息
+- 群事件同步
+
+#### push-service
+- message.new
+- message.recall
+- group.member_changed
+- device.revoked
+
+#### file-ticket-service
+- 上传票据
+- 下载票据
+- 文件元信息
+
+#### risk-device-service
+- 设备状态
+- 完整性校验
+- 风险拦截
+
+---
+
+## 5. 服务端数据模型
+
+## 5.1 用户与设备
+
+```mermaid
+erDiagram
+ USER ||--o{ DEVICE : owns
+ USER {
+ string user_id
+ string status
+ int64 created_at
+ }
+ DEVICE {
+ string device_id
+ string user_id
+ string platform
+ string device_model
+ string app_version
+ string virgil_identity
+ string status
+ int64 last_seen_at
+ }
+```
+
+## 5.2 会话与消息
+
+```mermaid
+erDiagram
+ CONVERSATION ||--o{ MESSAGE : contains
+ CONVERSATION {
+ string conversation_id
+ string type
+ string peer_key
+ string group_id
+ int64 created_at
+ }
+ MESSAGE {
+ string message_id
+ string conversation_id
+ string chat_type
+ string sender_user_id
+ string sender_device_id
+ string receiver_user_id
+ string group_id
+ int64 group_key_version
+ string content_type
+ int envelope_version
+ string cipher_suite
+ bytes ciphertext
+ bytes signature
+ int64 seq
+ int64 server_timestamp
+ }
+```
+
+## 5.3 群与群事件
+
+```mermaid
+erDiagram
+ GROUP ||--o{ GROUP_MEMBER : has
+ GROUP ||--o{ GROUP_KEY_EVENT : emits
+ GROUP {
+ string group_id
+ string conversation_id
+ string owner_user_id
+ string name
+ int64 group_key_version
+ string status
+ }
+ GROUP_MEMBER {
+ string group_id
+ string user_id
+ string role
+ string status
+ int64 join_version
+ int64 leave_version
+ }
+ GROUP_KEY_EVENT {
+ string event_id
+ string group_id
+ int64 group_key_version
+ string event_type
+ string operator_user_id
+ int64 created_at
+ }
+```
+
+---
+
+## 6. 服务端接口设计
+
+## 6.1 认证与设备
+
+### 登录
+```http
+POST /api/v1/auth/login
+```
+
+### 刷新 token
+```http
+POST /api/v1/auth/refresh
+```
+
+### 登出
+```http
+POST /api/v1/auth/logout
+```
+
+### 注册设备
+```http
+POST /api/v1/devices/register
+```
+
+### 查询设备列表
+```http
+GET /api/v1/devices
+```
+
+### 吊销设备
+```http
+POST /api/v1/devices/{device_id}:revoke
+```
+
+---
+
+## 6.2 Virgil JWT
+
+### 获取 Virgil JWT
+```http
+POST /api/v1/security/virgil-jwt
+```
+
+请求:
+```json
+{
+ "device_id": "ios_a1"
+}
+```
+
+响应:
+```json
+{
+ "virgil_jwt": "virgil_jwt_xxx",
+ "expires_in": 3600,
+ "virgil_identity": "u_1001:ios_a1"
+}
+```
+
+---
+
+## 6.3 会话
+
+### 创建/获取单聊
+```http
+POST /api/v1/conversations/single
+```
+
+### 会话列表
+```http
+GET /api/v1/conversations
+```
+
+### 会话详情
+```http
+GET /api/v1/conversations/{conversation_id}
+```
+
+### 会话设置
+```http
+POST /api/v1/conversations/{conversation_id}/settings
+```
+
+---
+
+## 6.4 消息
+
+### 发送消息
+```http
+POST /api/v1/messages
+```
+
+### 批量发送
+```http
+POST /api/v1/messages:batchSend
+```
+
+### 拉取指定消息
+```http
+GET /api/v1/messages/{message_id}
+```
+
+### 撤回消息
+```http
+POST /api/v1/messages/{message_id}:recall
+```
+
+### 仅自己删除
+```http
+POST /api/v1/messages/{message_id}:deleteForMe
+```
+
+---
+
+## 6.5 群组
+
+### 创建群
+```http
+POST /api/v1/groups
+```
+
+### 群详情
+```http
+GET /api/v1/groups/{group_id}
+```
+
+### 群上下文
+```http
+GET /api/v1/groups/{group_id}/context
+```
+
+### 群成员列表
+```http
+GET /api/v1/groups/{group_id}/members
+```
+
+### 加人
+```http
+POST /api/v1/groups/{group_id}/members:add
+```
+
+### 移除成员
+```http
+POST /api/v1/groups/{group_id}/members:remove
+```
+
+### 退群
+```http
+POST /api/v1/groups/{group_id}:leave
+```
+
+### 解散群
+```http
+POST /api/v1/groups/{group_id}:dismiss
+```
+
+### 修改群资料
+```http
+POST /api/v1/groups/{group_id}/profile
+```
+
+---
+
+## 6.6 文件
+
+### 申请上传票据
+```http
+POST /api/v1/files/upload-ticket
+```
+
+### 上传完成
+```http
+POST /api/v1/files/{file_id}:complete
+```
+
+### 获取下载票据
+```http
+POST /api/v1/files/{file_id}/download-ticket
+```
+
+### 获取文件元信息
+```http
+GET /api/v1/files/{file_id}
+```
+
+---
+
+## 6.7 同步
+
+### 冷启动 bootstrap
+```http
+GET /api/v1/sync/bootstrap
+```
+
+### 增量消息同步
+```http
+GET /api/v1/sync/messages
+```
+
+### 群事件同步
+```http
+GET /api/v1/sync/group-events
+```
+
+---
+
+## 6.8 回执与状态
+
+### 已送达/已读
+```http
+POST /api/v1/messages/ack
+```
+
+### 批量已读
+```http
+POST /api/v1/conversations/{conversation_id}:markRead
+```
+
+### 输入中
+```http
+POST /api/v1/conversations/{conversation_id}/typing
+```
+
+---
+
+## 6.9 风控与安全
+
+### 预检查
+```http
+POST /api/v1/security/precheck
+```
+
+### 设备完整性上报
+```http
+POST /api/v1/security/integrity-report
+```
+
+---
+
+## 7. 服务端核心流程
+
+## 7.1 签发 Virgil JWT
+
+```mermaid
+sequenceDiagram
+ participant C as 客户端
+ participant J as Virgil JWT Service
+ participant A as Auth Service
+ participant R as Risk / Device Service
+
+ C->>J: /security/virgil-jwt
+ J->>A: 校验 access_token
+ A-->>J: 用户有效
+
+ J->>R: 校验 device_id / 风险状态
+ R-->>J: 允许或拒绝
+
+ J-->>C: 返回 virgil_jwt
+```
+
+## 7.2 消息接收与路由
+
+```mermaid
+sequenceDiagram
+ participant C as 客户端
+ participant M as Message Service
+ participant S as Sync Service
+ participant P as Push Service
+ participant DB as Metadata DB
+
+ C->>M: POST /messages
+ M->>DB: 存储 envelope
+ M->>DB: 分配 message_id / seq
+ M->>S: 写离线同步流
+ M->>P: 触发新消息推送
+ M-->>C: 返回 accepted
+```
+
+## 7.3 群成员变更
+
+```mermaid
+sequenceDiagram
+ participant A as 管理员客户端
+ participant G as Group Service
+ participant DB as Metadata DB
+ participant S as Sync Service
+ participant P as Push Service
+
+ A->>G: /groups/{id}/members:add
+ G->>DB: 更新群成员
+ G->>DB: group_key_version + 1
+ G->>S: 写入 group event
+ G->>P: 推送 group.member_changed
+ G-->>A: 返回最新 group_key_version
+```
+
+---
+
+## 8. 服务端幂等与校验
+
+## 8.1 幂等设计
+
+### 发消息幂等键
+
+推荐:
+
+```text
+sender_user_id + sender_device_id + client_message_id
+```
+
+### 创建群幂等
+
+推荐使用:
+
+```http
+Idempotency-Key:
+```
+
+## 8.2 消息接口校验项
+
+- 当前用户是会话参与者
+- `sender_device_id` 属于当前用户
+- 单聊的 `receiver_user_id` 与会话匹配
+- 群聊的 `group_id` 与会话匹配
+- 当前用户是群成员
+- `group_key_version` 合法
+- `ciphertext` 长度不超过限制
+
+## 8.3 Virgil JWT 接口校验项
+
+- 业务 token 有效
+- `device_id` 属于当前用户
+- 设备状态为 active
+- 未被吊销
+- 风控未阻断
+
+---
+
+## 9. WebSocket / 推送事件建议
+
+### WebSocket 事件
+
+- `message.new`
+- `message.recall`
+- `message.ack`
+- `group.member_changed`
+- `group.profile_changed`
+- `device.revoked`
+- `security.force_logout`
+
+### 推送原则
+
+推送中不要放消息明文,建议只放:
+
+- 会话 ID
+- 发送方昵称
+- 占位提示
+
+---
+
+## 10. 服务端风险点
+
+- 若服务端误记录明文日志,会破坏 E2EE 边界
+- 若文件下载链接长期有效,文件密文泄露面会扩大
+- 若群成员变更后未提升 `group_key_version`,客户端可能使用旧上下文
+- 若被吊销设备仍可获取 Virgil JWT,会造成安全失控
+- 若推送带明文,会绕过加密链路
+
+---
+
+## 11. 推荐实施路径
+
+## 阶段 1:单聊 MVP
+
+实现:
+
+- 登录
+- Virgil JWT
+- 单聊消息发送
+- 增量同步
+- 已读回执
+
+## 阶段 2:群聊
+
+实现:
+
+- 创建群
+- 群成员管理
+- group_key_version
+- 群事件同步
+
+## 阶段 3:文件与恢复
+
+实现:
+
+- 文件票据
+- 加密文件上传下载
+- 新设备恢复
+- 风控增强
+
+---
+
+## 12. 服务器总结
+
+服务器在 Virgil Security 接入中的核心定位是:
+
+- 不参与消息解密
+- 不保存私钥
+- 只处理密文、元数据和业务控制逻辑
+
+具体来说,服务器负责:
+
+- 身份认证
+- 设备管理
+- Virgil JWT 签发
+- 密文消息接入、存储、路由
+- 会话与群成员关系
+- 文件票据
+- 同步与推送
+- 风控和设备吊销
+
+这样才能既保留现有聊天系统的业务能力,又维持端到端加密的安全边界。