Merge pull request #9 from sok-im/feature/virgil_v2

virgil 加密解密
pull/3727/head
haoyunlt 1 month ago committed by GitHub
commit 3d95f70689
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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)
}
}

@ -0,0 +1,14 @@
rpc:
registerIP: ''
listenIP: 0.0.0.0
autoSetPorts: true
ports:
- 10190
prometheus:
enable: true
ports:
- 20190
virgil:
appID: ''
appKey: ''
appKeyID: ''

@ -11,6 +11,7 @@ rpcRegisterName:
third: third
captcha: captcha
rtc: rtc
crypto: crypto
imAdminUserID: [ imAdmin ]

@ -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/KotlinJS 暂不支持) |
| 适用场景 | 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<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`,加密信息通过现有字段承载:
```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: 生成群共享密钥<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 群成员变更与密钥轮转时序图
```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 更新群票据<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`,仅携带 `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 APIGateway 暴露)
```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<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`,对比本地版本决定是否更新 |
## 七、数据模型(服务端新增表)
```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)

@ -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

@ -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=

@ -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)
}

@ -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)

@ -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(),
}
}

@ -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
}

@ -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 {

@ -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)
}

@ -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
}

@ -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)
}

@ -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)
}

@ -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
}

@ -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"`
}

@ -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)
}

@ -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: <uuid>
```
## 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 签发
- 密文消息接入、存储、路由
- 会话与群成员关系
- 文件票据
- 同步与推送
- 风控和设备吊销
这样才能既保留现有聊天系统的业务能力,又维持端到端加密的安全边界。
Loading…
Cancel
Save