# MeshSafe 文件安全检测服务 · 对外接口文档 | 项目 | 内容 | |---|---| | 文档版本 | v1.1(2026-10-27 对齐当前代码实现) | | 接口版本 | `/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", "verdict": "malicious", "score": 85.0, "action": "reject", "reason": "检测到 VBA 宏 + DDEAUTO 自动执行", "status": "completed", "file": { "sha256": "a1b2c3...", "md5": "d4e5f6...", "size": 284915, "real_type": "Microsoft Word 2007+", "real_ext": ".docx", "declared_ext": ".docx", "filename": "起诉状.docx" }, "findings": [ { "engine": "document", "layer": "L2", "severity": "critical", "rule": "DDEAutoExecution", "detail": "检测到 DDE/DDEAUTO 字段:DDEAUTO c:\\windows\\system32\\cmd.exe", "extracted_path": "word/settings.xml", "weight": 65.0, "meta": {} } ], "extracted": [ { "path": "word/vbaProject.bin", "filename": "vbaProject.bin", "size": 10240, "depth": 1, "sha256": "" } ], "triggered": [ { "type": "rule_override", "rule": "DDEAutoExecution", "action": "reject", "engine": "document" } ], "meta": { "scan_duration_ms": 1840, "engines": ["structure", "clamav", "document", "keywords", "yara", "binary"], "engine_errors": [], "clamav": { "version": "ClamAV 1.5.4", "signature_date": "2026-10-27" }, "policy_version": "2026.10.1", "policy_sha256": "…", "scanned_objects": 3, "scanned_at": "2026-10-27T08:30:00+00:00" } } ``` **响应 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 / pass_with_flag ``` --- ### 4.2 GET /api/v1/scans/{task_id} 查询扫描结果。同步模式下该接口用于重试补查,异步模式下用于轮询。 **路径参数**:`task_id` —— 提交扫描时返回的任务标识。 **响应 200** 结构与 `POST /api/v1/scans` 的同步响应一致。若任务尚未完成: ```json { "task_id": "…", "status": "running", "verdict": "pending", "action": "pending", "score": 0.0, "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 | 任务标识,全局唯一 | | `verdict` | enum | 总体判定,见 §5.2 | | `score` | float | 风险评分,0~100 | | `action` | enum | 处置建议,见 §5.3 | | `reason` | string | 人可读的判定理由,可直接展示给用户 | | `status` | enum | `pending` / `running` / `completed` / `failed` | ### 5.2 verdict 取值 | 值 | 含义 | |---|---| | `clean` | 未发现风险 | | `suspicious` | 存在可疑信号,需要关注或人工判断 | | `malicious` | 判定为恶意 | | `error` | 检测过程异常(如引擎不可用),结论不可信 | | `pending` | 尚未完成 | ### 5.3 action 取值 | 值 | 含义 | 建议调用方动作 | |---|---|---| | `pass` | 未发现问题 | 正常放行 | | `pass_with_flag` | 存在低风险信号 | 放行,但建议记录标记 | | `manual_review` | 需人工判断 | **暂不放行**,转入人工复核流程 | | `reject` | 判定不可放行 | 拒绝传输,向用户展示 `reason` | | `pending` | 尚未裁决 | 等待异步结果 | > **`action` 与 `verdict` 解耦使用。** 业务逻辑请依据 `action`,`verdict` 供人查看与统计。 ### 5.4 findings 结构 | 字段 | 类型 | 说明 | |---|---|---| | `engine` | string | 引擎名:`structure` / `extractor` / `clamav` / `document` / `yara` / `binary` | | `layer` | string | 检测层:`L0` / `L1` / `L2` / `L2B` / `L3` | | `severity` | enum | `info` / `low` / `medium` / `high` / `critical` | | `rule` | string | 规则标识 | | `detail` | string | 命中详情 | | `extracted_path` | string | 信号来自哪个内部路径(顶层文件为空) | | `weight` | float | 该信号的风险权重 | | `meta` | object | 引擎附加信息 | ### 5.5 严重度与权重对照 | 严重度 | 典型含义 | 权重区间 | |---|---|---| | `critical` | 确凿的恶意行为,自动执行、外联、路径穿越 | 50 ~ 90 | | `high` | 高度可疑,宏调用、危险 API、高熵加壳 | 30 ~ 80 | | `medium` | 可疑但存在合理场景,外部链接、可疑导入 | 15 ~ 45 | | `low` | 轻微信号,仅编码字符串、无签名 | 5 ~ 20 | | `info` | 信息性提示 | 0 | ### 5.6 file 结构 | 字段 | 说明 | |---|---| | `sha256` / `md5` | 文件哈希,用于去重与追溯 | | `size` | 字节数 | | `real_type` | 内容判定的真实类型描述 | | `real_ext` | 真实扩展名(由内容推导) | | `declared_ext` | 文件名声明的扩展名 | | `filename` | 原始文件名 | ### 5.7 meta 结构 | 字段 | 说明 | |---|---| | `scan_duration_ms` | 检测耗时(毫秒) | | `engines` | 参与的引擎列表 | | `engine_errors` | 不可用的引擎列表,非空说明结论可能不完整 | | `clamav` | ClamAV 版本与签名库编号 | | `policy_version` | 判定所依据的策略版本 | | `policy_sha256` | 策略文件哈希 | | `scanned_objects` | 实际检测的对象数(含递归解包出的子文件) | | `scanned_at` | 检测时间(UTC) | > **`meta.clamav` 与 `meta.policy_version` 建议留存**,用于检测结果的有效性举证。 --- ## 6. 检测规则清单 以下规则命中时会出现在 `findings` 中。**规则清单公开用于解释结论,不含检测规则的具体实现内容。** ### 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 与二进制层 | 规则 | 严重度 | 含义 | |---|---|---| | `` | 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 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 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 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 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(); 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 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 { ["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 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 联系方式 | 事项 | 联系 | |---|---| | 凭证申请与轮换 | 系统管理员 | | 策略调整 | 安全管理员 | | 接口异常与故障 | 运维值班 | --- **文档结束** · 配套文档:《策略配置说明》《等保三级对照表》《运维手册》