# 安全红线

> docId：`XD-OP-09` ｜ version：`1.0.0` ｜ status：`PUBLISHED`

这一篇是**必读**：下面每一条都有过真实事故。带 ❌ 的条目属于接入红线，违反会被平台风控处置。

## 1. 密钥纪律

- ✅ RSA2 密钥对在你**自己的服务器**上生成；公钥上传到门户，私钥留在本地受控存储。
- ❌ **私钥永不上传**给任何人（包括平台、包括「帮你调试」的人）。
- ❌ 私钥**永不进浏览器/小程序/App 前端**：签名只在你的服务端做。
- ❌ 私钥**不进代码仓库**、不进 CI 日志、不进镜像层。用密钥管理服务或受权限保护的凭证文件。
- ❌ **不把签名材料写进日志**（签名值、完整请求体、规范串都不行）。排障只需要 `requestId`。
- **一个应用一套密钥**；不要把同一套密钥复制到多个应用或多个环境。

## 2. 请求签名（`XD-Signature-v1`）

**六个头**：`X-XD-App-Id`、`X-XD-Timestamp`、`X-XD-Nonce`、`X-XD-Key-Id`、`X-XD-Request-Id`、`X-XD-Sign`。签名值 Base64（RSA-SHA256）。

待签名串固定 **11 行**，用 `\n` 连接：

```text
XD-Signature-v1
<METHOD>
<CANONICAL_PATH>
<CANONICAL_QUERY>
<TIMESTAMP>
<NONCE>
<APP_ID>
<KEY_ID>
<REQUEST_ID>
<CONTENT_TYPE>
<SHA256_HEX(rawBody)>
```

| 位置 | 规则（逐条都对，错一条就 401） |
| --- | --- |
| `METHOD` | 大写 ASCII（`GET`/`POST`） |
| `CANONICAL_PATH` | 绝对路径（**不含 host**），URL 解码**恰好一次**；不得含 `..`、重复斜杠、反斜杠、控制字符 |
| `CANONICAL_QUERY` | 键值各自解码一次后按 **RFC3986** 重编码；**键名排序**（重复键保留原相对顺序）；**无 query 时该行为空行** |
| `TIMESTAMP` | 秒级 epoch（强校验时间窗，服务器需 NTP 对时） |
| `NONCE` | 一次性随机串；**同一 nonce 复用会被判重放**（16–64 位可见 ASCII，详见 [API 参考 · 请求头](./03-api-reference.md)） |
| `APP_ID` / `KEY_ID` / `REQUEST_ID` | 与请求头**逐字一致**；三者与 `NONCE`、`TIMESTAMP`、`SIGN` 的长度与字符集约束见 [API 参考 · 请求头](./03-api-reference.md)（越界与缺失返回同一个 `400`，错误体不指出是哪一头；唯一例外：同名头重复会在 message 里点名该头） |
| `CONTENT_TYPE` | **小写**并**去掉 `;` 参数**（如 `application/json; charset=utf-8` → `application/json`）；**没有带这个头时该行为空行**（`GET` 且无请求体就是这种情况）——**不要**想当然写 `application/json`，那是另一个值，会得到 401 |
| `SHA256_HEX(rawBody)` | 对**原始请求体字节**做 SHA-256，小写十六进制；无请求体时为空串的哈希 |

> 编码差异**不会**被平台「帮你归一」：任何编码不同都表现为不同的规范路径/查询 → 签名不符。先用 [`examples/`](../examples/)（**在公开仓与「下载接入包」内；不在「下载文档包」内**）里的参考实现跑通，再改。

## 3. 响应验签（`XD-Response-v1`）

响应头：`X-XD-Response-Sign`、`X-XD-Response-Key-Id`、`X-XD-Response-Timestamp`、`X-XD-Response-Nonce`。待签名串 **8 行**：

```text
XD-Response-v1
<STATUS>
<CONTENT_TYPE>
<REQUEST_ID>
<TIMESTAMP>
<NONCE>
<KEY_ID>
<SHA256_HEX(rawBody)>
```

- `CONTENT_TYPE` 与请求侧**同一套归一化规则**：**小写**并**去掉 `;` 参数**（响应头通常是 `application/json; charset=utf-8`，参与签名的是 `application/json`）。这一条写错会让**每一个**响应都验不过。
- `STATUS` 是十进制 HTTP 状态码字符串；`SHA256_HEX(rawBody)` 是**实际交付字节**（不是重新序列化的 JSON）。
- **不是每个响应都带这四个头**：**鉴权链之前的拒绝一律不带**——来源 IP 拒绝（`403`）、请求体门卫拒绝（`400`/`413`）、六头形态非法（`400`）、验签失败（`401`）、nonce 重放（`409`）。所以正确做法是「**四头齐备才强制验签**，缺头按未签名响应处理并保留其 `code`/`requestId` 排障」，不要把「缺头」当篡改丢弃（那会让你在最需要错误码时看不到任何信息）。
- 与请求签名**不是同一套拼串**，不要复用同一段代码。参考实现：[`examples/node/verify_response.js`](../examples/node/verify_response.js)（**在公开仓与「下载接入包」内；不在「下载文档包」内**）。
- 平台公钥经**固定文档页与门户**双渠道发布（文档页见 [接入指南 · 平台公钥与指纹](./02-integration-guide.md#31-平台公钥与指纹公示)，含 PEM 正文；门户「应用凭证」页显示同一把钥的 `kid` 与指纹）；**不要在未验签的响应里取公钥**。

## 4. 事件验签（`XD-Webhook-v1`）

见 [webhook 验签](./05-webhook-verification.md)。要点复述：**7 行规范串**、从**原始字节**算哈希、按 `eventId` 去重、业务幂等、返回体字节正好是 `success`。

## 5. 回调与跳转

- ❌ **不信任任何未经签名校验的回调**：验签失败一律丢弃（哪怕内容看起来对）。
- ❌ **不要把回调当唯一真相**：必须接查单作为补偿路径（重复/乱序/丢失都要能自愈）。
- ✅ **`returnUrl` 一经使用即被冻结**；不要依赖「传新值改跳转目标」。
- ✅ **回跳不是自动的**：买家在收银页点「返回商家」才会带着一次性凭据回跳（该按钮只在**同一页面会话**里、首次观察到支付成功时提供；**扫码支付（PC 出码 → 手机付款）不受此限**）。买家不点、刷新过页面时**不会**发生回跳——终态仍只认查单与验签事件。
- ❌ **不要把交付内容（卡密、下载链接、密码）放进 `returnUrl` 的 query**——会被浏览器历史、日志、Referer 带走。交付只由你的服务端依据订单状态驱动。
- ❌ **不得把 `payUrl`/`qrCode` 转发到第三方站点或第三方二维码服务**（不得代理、不得改写、不得二次编码）。

## 6. 浏览器侧

- 收银页是**平台提供的页面**：按 [收银页对接](./06-cashier-integration.md) 的用法使用即可；商家自有页面里**不要**自行实现终端分派逻辑。
- ❌ 商家自有页面**不得**把平台返回的收款材料地址放进 `localStorage`/`sessionStorage`/Cookie。
- ❌ **不引入第三方 CDN / 统计 / 字体**到承载收款材料的页面（供应链面会被扩大，且可能泄露 Referer）。
- 收银链必须走 **HTTPS**；平台侧收银页带 `no-store` / `no-referrer` 等安全头，商家侧页面也不要放宽这些约束。

## 7. 请求来源

- 商家 API 校验**来源 IP**：出口 IP 需在门户白名单内。**校验默认开启**，可在门户「IP 白名单」页**自助关闭/开启**（关闭须短信二次验证，平台留痕）——关闭后平台不再做这一层校验，风险由你承担；白名单条目在关闭期间**原样保留**，重开是一键。
- **开启状态下白名单不能为空**：空清单等于所有调用必被拒，所以平台**拒绝把校验开在空清单上**（而不是让你把调用全打挂）——先加条目，再开校验。
- 判定口径：平台看的是**边缘节点实际观测到的对端地址**（不是你机器上 `ifconfig` 看到的地址）。多出口 / NAT / 自建代理的场景下，请以**实际发起连接的那个出口**为准，并把**全部**可能出口（含灾备）都加进白名单。
- 这个 `403` **不回显平台观测到的地址**（防探测）。它有**两条腿**，处置方式**不一样**：
  1. **平台边缘腿**（请求还没进到你的应用就被判掉）：信封另带 `details.reason`，取值只有七种——`PEER_NOT_TRUSTED`（对端不是平台边缘，例如绕开边缘直连）、`FORWARDED_HEADER_PRESENT` / `XFF_MISSING` / `XFF_CHAIN_ABNORMAL` / `XFF_LAST_INVALID` / `REAL_IP_MISSING` / `HEADER_CONFLICT`（转发链形态不符）。**这几种加白名单解决不了**，属于你的出口路径或平台侧部署问题：带 `requestId` 与出口 IP 走官网反馈渠道。
  2. **应用白名单腿**：你的出口 IP 不在门户白名单内。信封只有 `code`/`message`/`requestId` 三个字段（**没有** `details`），处置=把该出口加进白名单（灾备 / 多可用区出口都要加）。
- 自检方法：用生产出口发一笔**最小合法请求**（例如查一笔不存在的订单），拿到 `403` 说明来源没过，拿到 `404` 说明来源已通过。
- ❌ 不要把请求从不受控的代理/办公网出口发出；来源判定失败一律 `403`，且**不会**因为签名正确而放行。

## 8. 遇到拒绝时的正确反应

| 拒绝 | ❌ 错误反应 | ✅ 正确反应 |
| --- | --- | --- |
| `403 SOURCE_IP_REJECTED` | 换代理/换出口硬试 | 把出口 IP 加进门户白名单（若你已关闭来源校验，确认是否要开回来） |
| `422 CHANNEL_UNAVAILABLE` | 反复重试/换通道刷 | 按 [错误码与排障](./04-errors-and-troubleshooting.md) 判断是否为未放行通道，走官网反馈 |
| `429 RATE_LIMITED` | 紧循环重试 | 指数退避 + 复用 `requestId`（额度窗口见响应头 `RateLimit-*` / `Retry-After`） |
| `503 GUARD_UNAVAILABLE` | 改换路径绕过 | 退避重试；`503` 是**安全地拒绝**，绕不过也不该绕 |

> 本表用**简写**便于阅读；信封里的 `code` 一律是**全名**（带 `OPEN_API_` 前缀），逐字以 [错误码与排障](./04-errors-and-troubleshooting.md) 的总表为准。按本表字面去比对 `code` 永远匹配不上。

## 9. 泄露应急

怀疑私钥泄露：**立刻在门户轮换密钥**（见 [凭证与密钥管理](./12-credential-key-management.md)），并检查是否有人用旧密钥发起过请求。轮换后旧密钥进入失效流程，不需要你停机。
