Files
MeshSate/docs/接口文档.md

1280 lines
40 KiB
Markdown
Raw Permalink Normal View History

# MeshSafe 文件安全检测服务 · 对外接口文档
| 项目 | 内容 |
|---|---|
| 文档版本 | v1.2(2026-10-29 响应字段精简,只暴露调用方必需字段) |
| 接口版本 | `/api/v1` |
| 适用系统 | 光闸内外网文件摆渡系统 |
| 保密级别 | **内部交付文档**,请勿公开传播 |
---
## 目录
1. [服务概述](#1-服务概述)
2. [接入准备](#2-接入准备)
3. [认证与签名规范](#3-认证与签名规范)
4. [接口清单](#4-接口清单)
5. [数据模型](#5-数据模型)
6. [检测规则清单](#6-检测规则清单)
7. [错误处理](#7-错误处理)
8. [接入示例](#8-接入示例)
9. [附录](#9-附录)
---
## 1. 服务概述
### 1.1 能力范围
MeshSafe 是部署在内网隔离区的**文件安全检测专用服务**,通过标准 HTTP 接口接收文件并返回结构化检测结论。
**检测覆盖的风险类型:**
| 类型 | 说明 |
|---|---|
| 恶意代码 | 病毒、木马、勒索软件、挖矿程序、后门 |
| 主动执行风险 | VBA 宏、DDE 自动执行、双扩展名伪装、文档内嵌可执行对象、PDF 脚本 |
| 容器逃逸类构造 | 压缩炸弹、路径穿越、超深嵌套、文件名伪装 |
| 策略违规 | 文件类型不在允许清单、文件超限、加密文件 |
**服务不做的事:**
- 不修改、不改写被检测文件
- 不返回解包后的文件内容
- 不做内容分类分级与敏感信息识别
- 不做杀毒处置(隔离、删除由摆渡系统按 `action` 执行)
### 1.2 部署形态
服务部署在内网隔离区,**运行期不访问公网**。病毒库与检测规则通过单向导入方式离线更新。
接入方式:调用方与检测服务之间走 HTTPS,证书由内部 CA 签发。
### 1.3 同步与异步
| 模式 | 何时使用 | 返回 |
|---|---|---|
| **同步** | 文件较小(默认 ≤ 512 MB),需要立即拿到结论 | 请求内返回完整结论 |
| **异步** | 文件较大,或策略要求走沙箱深检 | 返回 `task_id`,需轮询查询 |
两种模式的**请求完全相同**,服务根据文件大小与策略自动选择。异步模式下调用方轮询 `GET /api/v1/scans/{task_id}` 即可。
---
## 2. 接入准备
### 2.1 申请凭证
向系统管理员申请一组凭证,获得:
| 字段 | 说明 |
|---|---|
| `client_id` | 调用方标识,如 `duandao-system-a` |
| `secret` | 调用方密钥,用于签名,**请勿泄露** |
多套调用方请分别申请,不要共用同一组凭证 —— 审计日志会按 `client_id` 区分调用来源。
### 2.2 凭证安全要求
- `secret` 仅存储在调用方服务端,**禁止**硬编码在客户端或前端代码中
- 建议通过环境变量或密钥管理服务注入
- 怀疑泄露时联系管理员轮换(轮换后旧凭证立即失效)
### 2.3 网络连通性
| 源 | 目标 | 端口 |
|---|---|---|
| 摆渡系统服务端 | 检测服务 | 443 |
---
## 3. 认证与签名规范
> **本章是全文最重要的部分。** 请完整阅读后再开始对接。
### 3.1 为什么需要签名
本服务面向内外网数据交换场景,接口可能被多个系统调用。为防止未授权调用、请求重放、以及检测结论被篡改,所有请求必须携带 HMAC 签名,所有响应也提供独立签名供验签。
### 3.2 请求头
| 头部 | 必填 | 说明 |
|---|---|---|
| `X-MS-Client-Id` | 是 | 调用方标识 |
| `X-MS-Timestamp` | 是 | Unix 秒级时间戳 |
| `X-MS-Nonce` | 是 | 随机串,32 位小写十六进制,**同一次请求不可重复使用** |
| `X-MS-Body-SHA256` | 是 | 请求体原始字节的 SHA256,小写十六进制 |
| `X-MS-Signature` | 是 | 请求签名,小写十六进制 |
### 3.3 待签名串构造
各部分以 `\n` 分隔,**末尾无换行符**:
```
METHOD \n PATH \n CANONICAL_QUERY \n BODY_SHA256 \n TIMESTAMP \n NONCE \n CLIENT_ID
```
| 部分 | 取值规则 |
|---|---|
| `METHOD` | 大写,如 `POST` |
| `PATH` | 请求路径,不含域名、不含查询串。例:`/api/v1/scans` |
| `CANONICAL_QUERY` | 查询参数按 key 升序排列,`k=v` 用 `&` 连接;无参数时为空字符串 |
| `BODY_SHA256` | 请求体原始字节的 SHA256,小写十六进制 |
| `TIMESTAMP` | Unix 秒级时间戳字符串 |
| `NONCE` | 与请求头一致 |
| `CLIENT_ID` | 与请求头一致 |
**示例**(`POST /api/v1/scans`,无查询参数,空请求体):
```
POST
/api/v1/scans
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
1700000000
0123456789abcdef0123456789abcdef
test-client
```
注意 `PATH` 与 `CANONICAL_QUERY` 之间有一行是空行 —— 因为查询参数为空。
### 3.4 签名密钥派生
**不要直接用 `secret` 做 HMAC 密钥**,必须先派生:
```
signing_key = HKDF-SHA256(
ikm = secret,
salt = client_id,
info = "meshsafe/api/v1/request-signing",
len = 32
)
```
```
signature = HMAC-SHA256(signing_key, 待签名串) → 小写十六进制
```
派生用途隔离的意义:即使请求签名密钥泄露,攻击者也无法伪造响应签名(响应使用独立的派生标签)。
> 大多数语言的标准库没有 HKDF,需要自行实现(约 15 行代码),示例见 §8。
### 3.5 文件上传的 body 哈希
上传文件时,请求体是 `multipart/form-data`,包含**随机 boundary**,因此:
**`X-MS-Body-SHA256` 必须对最终发送的完整 HTTP 请求体字节计算**(即 `Content-Type` 头之后的所有字节,含 boundary)。
正确做法:
```python
# 构造请求体后,对 bytes 计算哈希
body = build_multipart_body(file_bytes, filename)
body_hash = hashlib.sha256(body).hexdigest() # ← 对最终字节计算
headers = sign(secret, client_id, "POST", "/api/v1/scans", body=body)
# 然后用完全相同的 body 字节发送
requests.post(url, data=body, headers={**headers, "Content-Type": content_type})
```
**常见错误**:用 `files=` 参数让 HTTP 库自动构造 multipart —— 这样你无法预知最终 body 字节,也就无法正确计算哈希。**必须手工构造 body。**
### 3.6 时间戳与防重放
| 约束 | 值 |
|---|---|
| 允许时钟偏差 | ±300 秒 |
| nonce 复用 | 直接拒绝 |
服务端会记录已使用的 nonce。若因网络重试需要重发同一请求,**必须更换 nonce 与时间戳**。
### 3.7 响应签名与验签
响应头中包含:
| 头部 | 说明 |
|---|---|
| `X-MS-Response-Timestamp` | 响应生成时间戳 |
| `X-MS-Response-Signature` | 响应签名,小写十六进制 |
```
response_key = HKDF-SHA256(
ikm = secret,
salt = client_id,
info = "meshsafe/api/v1/response-signing",
len = 32
)
payload = SHA256(响应体原始字节) || X-MS-Response-Timestamp
signature = HMAC-SHA256(response_key, payload) → 小写十六进制
```
> **验签是调用方的强制责任。** 不验签意味着你无法确认结论未被篡改,也无法确认响应确实来自本服务。生产对接必须实现验签。
验签失败时**必须拒绝使用该结论**,并记录告警。
### 3.8 校验向量
以下向量可直接用于您的单元测试,验证签名实现是否正确。
| 参数 | 值 |
|---|---|
| `secret` | `test-secret` |
| `client_id` | `test-client` |
| `method` | `POST` |
| `path` | `/api/v1/scans` |
| `query` | 空 |
| `body` | 空 |
| `timestamp` | `1700000000` |
| `nonce` | `0123456789abcdef0123456789abcdef` |
期望结果:
| 项 | 期望值 |
|---|---|
| `body_sha256` | `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855` |
| `signing_key`(hex) | `e2c60ab7169f72b364aa0c17a1d96022c5ae1b1852ec8a2ad3c5d319a46f6e2f` |
| `signature`(hex) | `9d0bf5d6cf8e395a122d280d34199448807c5ed4604ae7555cc7ecb8d863654b` |
> **同时提供响应签名校验向量**(用于验证 `response-signing` 密钥派生和 HMAC 实现):
>
> | 参数 | 值 |
> |---|---|
> | `response_key`(hex) | `5059871d01a76283d43579cf50c00646f5d21b8bccc6123db32aebd9c741054e` |
> | 假设响应体 | 空(`e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`) |
> | `response_timestamp` | `1700000000` |
> | `response_signature` | `79818ae5f2dd07d74713e67251d823b0be155cce17699a568aef7b233588f84e` |
>
> > 待签名串 = `SHA256(响应体).hex() + response_timestamp` → 对 response_key 做 HMAC-SHA256。
> 若上面的 signature 与您的实现算出来不一致,说明 HKDF 或 HMAC 的实现有偏差(常见原因是 HKDF 的 counter 从 0 开始而非 1,或 HMAC 输出未转小写 hex)。
> 同时可使用在线调试台 `/console/signing` 现场生成签名进行比对。
### 3.9 认证失败的表现
所有认证失败统一返回 HTTP 401,响应体:
```json
{
"detail": "认证失败"
}
```
**不会**区分"client_id 不存在"与"签名错误",以防凭证枚举。
---
## 4. 接口清单
### 4.1 POST /api/v1/scans
提交文件进行安全检测。
**请求**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `file` | binary | 是 | 待检测文件 |
| `channel` | string | 否 | 业务通道标识,默认 `default`。本期为全局策略,通道仅用于日志追溯 |
| `priority` | string | 否 | `normal`(默认)/ `high`,仅影响异步任务的排队顺序 |
**Content-Type**:`multipart/form-data`
**响应 200(同步完成)**
```json
{
"task_id": "3f8a9c2e1b7d4e6f8a0b2c4d6e8f0a1b",
"action": "reject",
"reason": "检测到 VBA 宏 + DDEAUTO 自动执行",
"status": "completed",
"file": {
"filename": "起诉状.docx",
"sha256": "a1b2c3...",
"md5": "d4e5f6...",
"size": 284915,
"real_type": "Microsoft Word 2007+"
},
"meta": {
"scan_duration_ms": 1840,
"scanned_at": "2026-10-27T08:30:00Z"
}
}
```
> **设计原则**:接口只暴露调用方必须知道的字段,内部权重、严重度、引擎层、策略链等实现细节不对外返回。调用方拿到 `action` 即可直接决策,无需二次分析。
**响应 200(异步受理)**
```json
{
"task_id": "3f8a9c2e1b7d4e6f8a0b2c4d6e8f0a1b",
"status": "pending",
"message": "任务已受理,请轮询 /api/v1/scans/{task_id} 获取结果"
}
```
**调用方处理建议**
```python
result = scan(file_bytes, filename)
if result["status"] != "completed":
result = poll_until_done(result["task_id"])
action = result["action"]
if action == "reject":
reject_transfer(reason=result["reason"]) # 拦截,向用户展示 reason
elif action == "manual_review":
route_to_human_review(result["task_id"]) # 暂不放行,转人工
else:
allow_transfer() # pass,正常放行
```
---
### 4.2 GET /api/v1/scans/{task_id}
查询扫描结果。同步模式下该接口用于重试补查,异步模式下用于轮询。
**路径参数**:`task_id` —— 提交扫描时返回的任务标识。
**响应 200**
结构与 `POST /api/v1/scans` 的同步响应一致。若任务尚未完成:
```json
{
"task_id": "…",
"status": "running",
"action": "pending",
"reason": ""
}
```
`status` 取值:`pending` / `running` / `completed` / `failed`。
**响应 404**:任务不存在(同时排查 `client_id` 是否与提交时一致)。
---
### 4.3 POST /api/v1/scans/{task_id}/rescan
使用**当前生效策略**重新扫描同一文件。
**用途**:策略调整后,对历史文件重新判定;或人工复核阶段需要二次深检。
**响应 200**:同 4.1。
> 若原始文件已按留存策略清理,则返回 404。
---
### 4.4 POST /api/v1/scans/{task_id}/review
回写人工复核结论。
**请求体**
```json
{
"decision": "pass",
"comment": "经人工查验为已脱敏的内部文书,无风险"
}
```
| 字段 | 取值 | 说明 |
|---|---|---|
| `decision` | `pass` / `reject` | 复核结论 |
| `comment` | string | 复核说明,必填(审计要求) |
**响应 200**
```json
{ "task_id": "…", "decision": "pass", "recorded_at": "2026-10-27T09:00:00+00:00" }
```
**权限**:需 `operator` 角色。
---
### 4.5 GET /api/v1/health
健康检查与引擎状态。
**响应 200**
```json
{
"status": "healthy",
"policy_version": "2026.10.1",
"engines": {
"clamav": {
"available": true,
"version": "ClamAV 0.105.2/27012",
"signature": "27012",
"signature_updated_at": "2026-10-26T22:00:00+00:00"
},
"yara": { "available": true, "rules_loaded": true, "rules_dir": "/app/rules" },
"document": { "available": true },
"structure": { "available": true },
"binary": { "available": true }
},
"queue": { "pending": 0, "running": 0 },
"server_time": "2026-10-27T08:30:00+00:00"
}
```
**用途**:调用方启动时自检;运维监控告警(尤其 `clamav.available = false` 或签名库超过 7 天未更新)。
---
### 4.6 GET /api/v1/policies
查询当前生效的全局策略(只读)。
**响应 200**:返回完整策略 YAML 的 JSON 形式,含 `version`、`sha256`、`content`。
**权限**:`operator`。
---
### 4.7 POST /api/v1/policies/reload
从策略文件重新加载策略。
**用途**:修改策略文件后无需重启服务。
**响应 200**
```json
{ "reloaded": true, "version": "2026.10.2", "sha256": "…" }
```
**权限**:`operator`。
> 策略文件校验不通过时拒绝加载并保留原策略,响应 400 并说明校验错误原因。
---
### 4.8 GET /api/v1/audit/verify
校验审计日志哈希链完整性。
**响应 200**
```json
{
"checked": 1000,
"intact": true,
"broken_at_id": null,
"verified_at": "2026-10-27T08:30:00+00:00"
}
```
**权限**:`operator`。
---
## 5. 数据模型
### 5.1 响应字段
| 字段 | 类型 | 说明 |
|---|---|---|
| `task_id` | string | 任务标识,全局唯一,用于后续查询与审计追溯 |
| `action` | enum | **调用方决策依据**,见 §5.2 |
| `reason` | string | 人可读的判定理由,可直接展示给用户 |
| `status` | enum | `pending` / `running` / `completed` / `failed` |
| `file` | object | 文件追溯信息,见 §5.3 |
| `meta` | object | 基础元信息,见 §5.4 |
### 5.2 action 取值(调用方唯一决策依据)
| 值 | 含义 | 调用方动作 |
|---|---|---|
| `pass` | 未发现风险或仅极轻微信号 | 正常放行 |
| `manual_review` | 可疑,需人工判断 | **暂不放行**,转入人工复核流程 |
| `reject` | 判定不可放行 | 拒绝传输,向用户展示 `reason` |
| `pending` | 异步尚未完成 | 等待轮询 |
> **`action` 是调用方唯一需要判断的字段**,不需要关心内部权重、严重度、触发了几条规则。
### 5.3 file 结构
| 字段 | 说明 |
|---|---|
| `filename` | 原始文件名 |
| `sha256` | 文件哈希,用于去重与追溯举证 |
| `md5` | 文件哈希,兼容场景使用 |
| `size` | 字节数 |
| `real_type` | 内容判定的真实类型描述(如 `JPEG image data`) |
> 不对外暴露 `real_ext` / `declared_ext`(内部判定使用)。
### 5.4 meta 结构
| 字段 | 说明 |
|---|---|
| `scan_duration_ms` | 本次检测耗时(毫秒) |
| `scanned_at` | 检测时间(UTC) |
> 不对外暴露 `engines` / `engine_errors` / `clamav` 版本 / `policy_version` / `policy_sha256` / `scanned_objects` 等内部实现细节。这些信息保留在服务端控制台与审计日志中,运维人员按需查询。
---
### 5.5 内部判定字段(不对外暴露)
以下字段存在于服务端内部(控制台、审计日志、DB 记录),不通过 API 返回。调用方无需关心。
**verdict(内部总体判定)**
| 值 | 含义 |
|---|---|
| `clean` | 未发现风险 |
| `suspicious` | 存在可疑信号 |
| `malicious` | 判定为恶意 |
| `error` | 检测过程异常 |
| `pending` | 尚未完成 |
**score(内部权重和,0~100)** — 调用方不做二次分析,不对外暴露。
**findings(内部命中信号列表)** — 每条含 engine / layer / severity / rule / detail / weight / meta 等字段,供安全团队审计与问题追溯。不对外暴露。
**triggered(内部策略链触发记录)** — rule_override / zero_tolerance 等,不对外暴露。
**open_risk / extracted(内部辅助分析与解包过程)** — 不对外暴露。
---
## 6. 检测规则清单
以下规则命中时会在服务端审计日志中留下记录,调用方通过 `reason` 字段获得人可读的结论说明。**规则清单公开用于解释结论,不含检测规则的具体实现内容。**
### 6.1 L0 结构层
| 规则 | 严重度 | 含义 |
|---|---|---|
| `ExtensionMismatch` | high | 扩展名声明与真实内容类型不符 |
| `DoubleExtension` | high | 文件名含多个扩展名,疑似伪装 |
| `DangerousExecutableExtension` | critical / high | 文件类型为可执行或脚本 |
| `ArchiveContainsExecutable` | high | 归档内含可执行文件 |
| `ArchiveBombSuspect` | high | 压缩比异常,疑似压缩炸弹 |
| `ArchiveTooLarge` | high | 解压后体积超限 |
| `ArchiveTooManyMembers` | medium | 归档成员数量异常 |
| `PathTraversal` | critical | 归档内含路径穿越条目 |
| `PathTraversalBlocked` | critical | 已拦截路径穿越条目 |
| `ArchiveBombBlocked` | high | 已拦截高压缩比成员 |
| `ArchiveUnreadable` | medium | 归档结构损坏 |
| `ExtractDepthExceeded` | medium | 嵌套层级超限 |
| `ExtractBudgetExceeded` | medium | 解包数量或体积超限 |
| `MemberTooLarge` | medium | 成员大小超限已跳过 |
### 6.2 L1 ClamAV 层
| 规则 | 严重度 | 含义 |
|---|---|---|
| `ClamAV.<签名名>` | critical / high / medium | 命中病毒库签名。签名名随病毒库更新而变化 |
### 6.3 L2 文档深度层
| 规则 | 严重度 | 含义 |
|---|---|---|
| `VBAAutoExecShell` | critical | 宏含自动执行入口并调用系统命令或外联 |
| `VBAShellInvocation` | high | 宏调用 Shell / CreateObject 执行外部命令 |
| `VBAAutoExec` | high | 宏含自动执行入口 |
| `VBAExternalLink` | medium | 宏中含外部链接 |
| `VBAEncodedString` | medium | 宏中含疑似编码混淆长串 |
| `VBAMacroPresent` | low | 文档含宏,需人工确认用途 |
| `MRaptorAutoExecSuspicious` | critical | 宏具备自动执行且含可疑行为 |
| `MRaptorAutoExec` | high | 宏含自动执行入口 |
| `DDEAutoExecution` | critical | 含 DDE / DDEAUTO 自动执行字段 |
| `EmbeddedExecutable` | critical | 文档内嵌可执行对象 |
| `EmbeddedOleObject` | medium | 文档内嵌 OLE 对象 |
| `ExternalLinkReference` | high | 工作簿含外部链接部件 |
| `ExternalUri` | high | 文档含指向外部地址的链接 |
| `AttachedTemplate` | high | 关联外部模板,可能触发模板注入 |
| `DocVars` | medium | 含文档变量,可能用于隐藏载荷 |
| `EmbeddedObject` | medium | 文档内嵌对象 |
| `PdfJavaScript` | critical | PDF 含 JavaScript 动作 |
| `PdfLaunchAction` | critical | PDF 含启动外部程序动作 |
| `PdfOpenAction` | high | PDF 定义了打开时自动执行的动作 |
| `PdfExternalUri` | medium | PDF 含外部 URI |
| `PdfEmbeddedFile` | high | PDF 内嵌文件 |
| `PdfEncrypted` | medium | PDF 已加密,无法完整静态分析 |
| `SuspiciousScript` | critical / high | 脚本含下载器、编码执行、关闭防护等可疑行为 |
### 6.4 L2B YARA 与二进制层
| 规则 | 严重度 | 含义 |
|---|---|---|
| `<YARA 规则名>` | critical ~ low | 命中 YARA 规则 |
| `HighEntropySection` | medium | 节区熵值过高,可能加密或加壳 |
| `SuspiciousImports` | high | 导入表含高危 API |
| `UnsignedExecutable` | medium | 可执行文件未携带数字签名 |
| `SuspiciousTimestamp` | low | PE 编译时间戳异常 |
| `PeParseFailed` | medium | PE 结构解析失败 |
---
## 7. 错误处理
### 7.1 HTTP 状态码
| 状态码 | 含义 | 调用方应对 |
|---|---|---|
| 200 | 成功 | 正常处理结论 |
| 400 | 请求参数错误 / 策略校验失败 | 检查请求,不要盲目重试 |
| 401 | 认证失败 | 检查签名实现与时间戳同步 |
| 403 | 权限不足 | 检查凭证角色 |
| 404 | 资源不存在 | 检查 task_id |
| 413 | 文件超限 | 缩小文件或走异步流程 |
| 429 | 触发限流 | 退避后重试,建议指数退避 |
| 500 | 服务内部错误 | 重试;持续失败则告警 |
| 503 | 服务不可用(引擎未就绪) | 退避重试 |
### 7.2 错误响应格式
```json
{
"detail": "认证失败"
}
```
### 7.3 重试策略建议
| 状态码 | 是否重试 | 建议 |
|---|---|---|
| 400 / 401 / 403 / 404 | 否 | 属调用方问题,重试无意义 |
| 413 | 否 | 需改变请求内容 |
| 429 | 是 | 退避 1s → 2s → 4s,最大 3 次 |
| 500 / 503 | 是 | 退避重试,最大 3 次;持续失败转人工 |
> 网络超时导致的失败重试时,**必须更换 nonce 与时间戳**(见 §3.6)。
---
## 8. 接入示例
### 8.1 Python 完整实现
```python
import hashlib
import hmac
import json
import os
import time
import uuid
import httpx
EMPTY_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
def hkdf_sha256(ikm: bytes, salt: bytes, info: bytes, length: int = 32) -> bytes:
"""HKDF-SHA256。RFC 5869,仅需 extract + expand 两步。"""
prk = hmac.new(salt, ikm, hashlib.sha256).digest()
okm = b""
block = b""
counter = 1
while len(okm) < length:
block = hmac.new(prk, block + info + bytes([counter]), hashlib.sha256).digest()
okm += block
counter += 1
return okm[:length]
def derive_key(secret: str, client_id: str, purpose: str) -> bytes:
return hkdf_sha256(
ikm=secret.encode("utf-8"),
salt=client_id.encode("utf-8"),
info=purpose.encode("utf-8"),
length=32,
)
def canonical_query(params: dict) -> str:
if not params:
return ""
return "&".join(f"{k}={params[k]}" for k in sorted(params.keys()))
def build_multipart(file_bytes: bytes, filename: str, field_name: str = "file",
extra: dict | None = None) -> tuple[bytes, str]:
"""手工构造 multipart 请求体,返回 (body_bytes, content_type)。
必须手工构造,因为签名需要基于最终字节计算 body 哈希。
"""
boundary = "----MeshSafeBoundary" + uuid.uuid4().hex
parts: list[bytes] = []
for key, value in (extra or {}).items():
parts.append(
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="{key}"\r\n\r\n'
f"{value}\r\n".encode("utf-8")
)
safe_name = filename.replace('"', "")
parts.append(
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="{field_name}"; filename="{safe_name}"\r\n'
f"Content-Type: application/octet-stream\r\n\r\n".encode("utf-8")
)
parts.append(file_bytes)
parts.append(f"\r\n--{boundary}--\r\n".encode("utf-8"))
return b"".join(parts), f"multipart/form-data; boundary={boundary}"
def sign_headers(secret: str, client_id: str, method: str, path: str,
query: dict | None, body: bytes) -> dict:
ts = str(int(time.time()))
nonce = uuid.uuid4().hex
body_hash = hashlib.sha256(body).hexdigest()
qs = canonical_query(query or {})
payload = "\n".join([
method.upper(), path, qs, body_hash, ts, nonce, client_id,
])
key = derive_key(secret, client_id, "meshsafe/api/v1/request-signing")
signature = hmac.new(key, payload.encode("utf-8"), hashlib.sha256).hexdigest()
return {
"X-MS-Client-Id": client_id,
"X-MS-Timestamp": ts,
"X-MS-Nonce": nonce,
"X-MS-Body-SHA256": body_hash,
"X-MS-Signature": signature,
}
def verify_response(secret: str, client_id: str, body: bytes,
timestamp: str, signature: str) -> bool:
"""校验响应签名。返回 False 表示结论不可信,必须拒绝使用。"""
key = derive_key(secret, client_id, "meshsafe/api/v1/response-signing")
payload = hashlib.sha256(body).hexdigest() + timestamp
expected = hmac.new(key, payload.encode("utf-8"), hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
class MeshSafeClient:
def __init__(self, base_url: str, client_id: str, secret: str,
verify_tls: bool = True, timeout: float = 120.0):
self.base_url = base_url.rstrip("/")
self.client_id = client_id
self.secret = secret
self._client = httpx.Client(verify=verify_tls, timeout=timeout)
def scan(self, file_bytes: bytes, filename: str,
channel: str = "default") -> dict:
body, content_type = build_multipart(
file_bytes, filename, extra={"channel": channel}
)
path = "/api/v1/scans"
headers = sign_headers(
self.secret, self.client_id, "POST", path, None, body
)
headers["Content-Type"] = content_type
resp = self._client.post(
self.base_url + path, content=body, headers=headers
)
# 响应验签:不可省略
resp_ts = resp.headers.get("X-MS-Response-Timestamp", "")
resp_sig = resp.headers.get("X-MS-Response-Signature", "")
if not resp_ts or not resp_sig:
raise RuntimeError("响应缺少签名头,拒绝使用该结论")
if not verify_response(
self.secret, self.client_id, resp.content, resp_ts, resp_sig
):
raise RuntimeError("响应签名校验失败,拒绝使用该结论")
resp.raise_for_status()
return resp.json()
def poll(self, task_id: str, interval: float = 2.0, timeout: float = 600.0) -> dict:
path = f"/api/v1/scans/{task_id}"
deadline = time.time() + timeout
while time.time() < deadline:
headers = sign_headers(
self.secret, self.client_id, "GET", path, None, b""
)
resp = self._client.get(self.base_url + path, headers=headers)
resp.raise_for_status()
data = resp.json()
if data.get("status") in {"completed", "failed"}:
return data
time.sleep(interval)
raise TimeoutError(f"任务 {task_id} 超时")
# 使用示例
if __name__ == "__main__":
client = MeshSafeClient(
base_url="https://meshsafe.internal",
client_id="duandao-system-a",
secret="<你的 secret>",
)
with open("起诉状.docx", "rb") as fh:
data = fh.read()
result = client.scan(data, "起诉状.docx", channel="court_docs")
if result.get("status") != "completed":
result = client.poll(result["task_id"])
print(result["action"], result["reason"])
```
### 8.2 Java 示例
```java
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.ByteArrayOutputStream;
import java.net.URI;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.*;
import java.util.UUID;
public class MeshSafeClient {
private static byte[] hkdfSha256(byte[] ikm, byte[] salt, byte[] info, int length) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(salt, "HmacSHA256"));
byte[] prk = mac.doFinal(ikm);
byte[] okm = new byte[length];
byte[] block = new byte[0];
int counter = 1;
int offset = 0;
while (offset < length) {
mac.init(new SecretKeySpec(prk, "HmacSHA256"));
mac.update(block);
mac.update(info);
mac.update(new byte[]{(byte) counter});
block = mac.doFinal();
int n = Math.min(block.length, length - offset);
System.arraycopy(block, 0, okm, offset, n);
offset += n;
counter++;
}
return okm;
} catch (Exception e) {
throw new RuntimeException(e);
}
}
private static byte[] deriveKey(String secret, String clientId, String purpose) {
return hkdfSha256(
secret.getBytes(StandardCharsets.UTF_8),
clientId.getBytes(StandardCharsets.UTF_8),
purpose.getBytes(StandardCharsets.UTF_8),
32);
}
private static String sha256Hex(byte[] data) throws Exception {
MessageDigest md = MessageDigest.getInstance("SHA-256");
return hex(md.digest(data));
}
private static String hex(byte[] bytes) {
StringBuilder sb = new StringBuilder(bytes.length * 2);
for (byte b : bytes) sb.append(String.format("%02x", b));
return sb.toString();
}
private static String hmacHex(byte[] key, String message) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(key, "HmacSHA256"));
return hex(mac.doFinal(message.getBytes(StandardCharsets.UTF_8)));
}
static Map<String, String> signHeaders(
String secret, String clientId, String method, String path,
String query, byte[] body) throws Exception {
String ts = String.valueOf(Instant.now().getEpochSecond());
String nonce = UUID.randomUUID().toString().replace("-", "");
String bodyHash = sha256Hex(body);
String payload = String.join("\n",
method.toUpperCase(), path, query == null ? "" : query,
bodyHash, ts, nonce, clientId);
byte[] key = deriveKey(secret, clientId, "meshsafe/api/v1/request-signing");
String signature = hmacHex(key, payload);
Map<String, String> headers = new HashMap<>();
headers.put("X-MS-Client-Id", clientId);
headers.put("X-MS-Timestamp", ts);
headers.put("X-MS-Nonce", nonce);
headers.put("X-MS-Body-SHA256", bodyHash);
headers.put("X-MS-Signature", signature);
return headers;
}
public static void main(String[] args) throws Exception {
String baseUrl = "https://meshsafe.internal";
String clientId = "duandao-system-a";
String secret = "<你的 secret>";
byte[] fileBytes = java.nio.file.Files.readAllBytes(
java.nio.file.Path.of("起诉状.docx"));
// 手工构造 multipart
String boundary = "----MeshSafeBoundary" + UUID.randomUUID().toString().replace("-", "");
ByteArrayOutputStream bos = new ByteArrayOutputStream();
bos.write(("--" + boundary + "\r\n"
+ "Content-Disposition: form-data; name=\"file\"; filename=\"doc.docx\"\r\n"
+ "Content-Type: application/octet-stream\r\n\r\n").getBytes(StandardCharsets.UTF_8));
bos.write(fileBytes);
bos.write(("\r\n--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8));
byte[] body = bos.toByteArray();
Map<String, String> headers = signHeaders(
secret, clientId, "POST", "/api/v1/scans", "", body);
headers.put("Content-Type", "multipart/form-data; boundary=" + boundary);
HttpClient http = HttpClient.newHttpClient();
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/api/v1/scans"))
.header("Content-Type", headers.get("Content-Type"))
.POST(HttpRequest.BodyPublishers.ofByteArray(body))
.build();
headers.forEach((k, v) -> { if (!k.equals("Content-Type")) req.headers().set(k, v); });
HttpResponse<byte[]> resp = http.send(req, HttpResponse.BodyHandlers.ofByteArray());
System.out.println(new String(resp.body(), StandardCharsets.UTF_8));
}
}
```
### 8.3 C# 示例
```csharp
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
public class MeshSafeClient
{
private readonly string _baseUrl;
private readonly string _clientId;
private readonly string _secret;
private readonly HttpClient _http;
public MeshSafeClient(string baseUrl, string clientId, string secret)
{
_baseUrl = baseUrl.TrimEnd('/');
_clientId = clientId;
_secret = secret;
_http = new HttpClient();
}
private static byte[] HkdfSha256(byte[] ikm, byte[] salt, byte[] info, int length)
{
using var hmac = new HMACSHA256(salt);
byte[] prk = hmac.ComputeHash(ikm);
using var expand = new HMACSHA256(prk);
var okm = new byte[length];
byte[] block = Array.Empty<byte>();
int counter = 1, offset = 0;
while (offset < length)
{
var input = block.Concat(info).Concat(new[] { (byte)counter }).ToArray();
block = expand.ComputeHash(input);
int n = Math.Min(block.Length, length - offset);
Array.Copy(block, 0, okm, offset, n);
offset += n;
counter++;
}
return okm;
}
private byte[] DeriveKey(string purpose) =>
HkdfSha256(
Encoding.UTF8.GetBytes(_secret),
Encoding.UTF8.GetBytes(_clientId),
Encoding.UTF8.GetBytes(purpose),
32);
private static string Sha256Hex(byte[] data) =>
Convert.ToHexString(SHA256.HashData(data)).ToLowerInvariant();
private static string HmacHex(byte[] key, string message)
{
using var hmac = new HMACSHA256(key);
return Convert.ToHexString(
hmac.ComputeHash(Encoding.UTF8.GetBytes(message))).ToLowerInvariant();
}
private Dictionary<string, string> SignHeaders(
string method, string path, string query, byte[] body)
{
string ts = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
string nonce = Guid.NewGuid().ToString("N");
string bodyHash = Sha256Hex(body);
string qs = query ?? "";
string payload = string.Join("\n",
method.ToUpper(), path, qs, bodyHash, ts, nonce, _clientId);
string signature = HmacHex(
DeriveKey("meshsafe/api/v1/request-signing"), payload);
return new Dictionary<string, string>
{
["X-MS-Client-Id"] = _clientId,
["X-MS-Timestamp"] = ts,
["X-MS-Nonce"] = nonce,
["X-MS-Body-SHA256"] = bodyHash,
["X-MS-Signature"] = signature,
};
}
public async Task<string> ScanAsync(byte[] fileBytes, string filename)
{
string boundary = "----MeshSafeBoundary" + Guid.NewGuid().ToString("N");
using var ms = new MemoryStream();
var head = Encoding.UTF8.GetBytes(
$"--{boundary}\r\n" +
$"Content-Disposition: form-data; name=\"file\"; filename=\"{filename}\"\r\n" +
"Content-Type: application/octet-stream\r\n\r\n");
ms.Write(head);
ms.Write(fileBytes);
ms.Write(Encoding.UTF8.GetBytes($"\r\n--{boundary}--\r\n"));
byte[] body = ms.ToArray();
var headers = SignHeaders("POST", "/api/v1/scans", "", body);
using var req = new HttpRequestMessage(HttpMethod.Post, $"{_baseUrl}/api/v1/scans");
req.Content = new ByteArrayContent(body);
req.Content.Headers.TryAddWithoutValidation(
"Content-Type", $"multipart/form-data; boundary={boundary}");
foreach (var kv in headers)
req.Headers.TryAddWithoutValidation(kv.Key, kv.Value);
var resp = await _http.SendAsync(req);
resp.EnsureSuccessStatusCode();
return await resp.Content.ReadAsStringAsync();
}
}
```
### 8.4 Go 示例
```go
package meshsafe
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"net/textproto"
"sort"
"strings"
"time"
)
// hkdfSHA256 实现 RFC 5869 的 HKDF-SHA256。
func hkdfSHA256(ikm, salt, info []byte, length int) []byte {
extractor := hmac.New(sha256.New, salt)
extractor.Write(ikm)
prk := extractor.Sum(nil)
var okm []byte
var block []byte
for counter := byte(1); len(okm) < length; counter++ {
h := hmac.New(sha256.New, prk)
h.Write(block)
h.Write(info)
h.Write([]byte{counter})
block = h.Sum(nil)
okm = append(okm, block...)
}
return okm[:length]
}
func deriveKey(secret, clientID, purpose string) []byte {
return hkdfSHA256(
[]byte(secret),
[]byte(clientID),
[]byte(purpose),
32,
)
}
func sha256Hex(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
func hmacHex(key []byte, message string) string {
mac := hmac.New(sha256.New, key)
mac.Write([]byte(message))
return hex.EncodeToString(mac.Sum(nil))
}
// SignHeaders 生成请求签名头。
func SignHeaders(secret, clientID, method, path, query string, body []byte) map[string]string {
ts := fmt.Sprintf("%d", time.Now().Unix())
nonce := fmt.Sprintf("%032x", time.Now().UnixNano())
bodyHash := sha256Hex(body)
payload := strings.Join([]string{
strings.ToUpper(method), path, query, bodyHash, ts, nonce, clientID,
}, "\n")
signature := hmacHex(
deriveKey(secret, clientID, "meshsafe/api/v1/request-signing"),
payload,
)
return map[string]string{
"X-MS-Client-Id": clientID,
"X-MS-Timestamp": ts,
"X-MS-Nonce": nonce,
"X-MS-Body-SHA256": bodyHash,
"X-MS-Signature": signature,
}
}
// Scan 提交文件扫描。
func Scan(baseURL, clientID, secret string, fileName string, fileBytes []byte) ([]byte, error) {
var buf bytes.Buffer
boundary := "----MeshSafeBoundary" + fmt.Sprintf("%032x", time.Now().UnixNano())
buf.WriteString("--" + boundary + "\r\n")
buf.WriteString("Content-Disposition: form-data; name=\"file\"; filename=\"" + fileName + "\"\r\n")
buf.WriteString("Content-Type: application/octet-stream\r\n\r\n")
buf.Write(fileBytes)
buf.WriteString("\r\n--" + boundary + "--\r\n")
body := buf.Bytes()
path := "/api/v1/scans"
headers := SignHeaders(secret, clientID, "POST", path, "", body)
req, err := http.NewRequest(http.MethodPost, baseURL+path, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "multipart/form-data; boundary="+boundary)
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
// 响应验签
if ts := resp.Header.Get("X-MS-Response-Timestamp"); ts != "" {
sig := resp.Header.Get("X-MS-Response-Signature")
expected := hmacHex(
deriveKey(secret, clientID, "meshsafe/api/v1/response-signing"),
sha256Hex(respBody)+ts,
)
if !hmac.Equal([]byte(expected), []byte(sig)) {
return nil, fmt.Errorf("响应签名校验失败")
}
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("接口返回 %d: %s", resp.StatusCode, string(respBody))
}
return respBody, nil
}
// canonicalQuery 按 key 升序生成查询串,供 GET 请求签名使用。
func canonicalQuery(params map[string]string) string {
if len(params) == 0 {
return ""
}
keys := make([]string, 0, len(params))
for k := range params {
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
parts = append(parts, k+"="+params[k])
}
return strings.Join(parts, "&")
}
var _ = textproto.MIMEHeader{}
```
---
## 9. 附录
### 9.1 快速排查清单
对接出现问题时,按此顺序检查:
| # | 检查项 | 常见错误 |
|---|---|---|
| 1 | 系统时钟同步 | 服务器与调用方时钟偏差超过 300 秒 |
| 2 | 签名密钥派生 | 直接用 `secret` 做 HMAC 密钥,未做 HKDF 派生 |
| 3 | 待签名串顺序 | 各部分顺序或分隔符不符 |
| 4 | 换行符 | 使用了 `\r\n` 而非 `\n` |
| 5 | 十六进制大小写 | 输出了大写 hex |
| 6 | body 哈希 | 用 `files=` 自动构造 multipart,未基于最终字节计算 |
| 7 | nonce 复用 | 重试时沿用了同一个 nonce |
| 8 | PATH 取值 | 包含了域名或查询串 |
| 9 | 响应验签 | 验签时用了请求签名密钥而非响应签名密钥 |
| 10 | 密钥编码 | secret 前后有多余空格 |
### 9.2 术语
| 术语 | 说明 |
|---|---|
| 摆渡 | 通过光闸在内外网之间传输文件 |
| 结论文本 | 一次检测的最终判定结果 |
| findings | 一次检测中命中的全部信号明细 |
| verdict | 总体判定(clean / suspicious / malicious / error) |
| action | 处置建议(pass / pass_with_flag / manual_review / reject) |
| 幂等 | 同一文件哈希与同一策略版本只检测一次,重复请求直接复用结果 |
### 9.3 联系方式
| 事项 | 联系 |
|---|---|
| 凭证申请与轮换 | 系统管理员 |
| 策略调整 | 安全管理员 |
| 接口异常与故障 | 运维值班 |
---
**文档结束** · 配套文档:《策略配置说明》《等保三级对照表》《运维手册》