安全
本章汇整散落在认证、沙盒、错误与回调各章的整合安全指引。当你准备上线、执行安全审查,或稽核人员询问 Payment Gateway 在传输层提供哪些保证、你这端又该做什么时,请阅读本章。安全是共同责任模型:Payment Gateway 强化平台、你强化整合,任一方独自都不足够。
威胁模型
下表列出商户 API 接口所要抵御的威胁,以及每项缓解措施的责任归属。「双方」代表两侧都存在控制机制,缺一不可。
| 威胁 | 缓解措施 | 责任归属 |
|---|---|---|
| 对入站请求的重放攻击 | 服务器端强制 X-Timestamp ±5 分钟窗口;单靠签名无法防止重放。 | Payment Gateway |
| 对入站回调的重放攻击 | 你的回调处理器套用相同的 ±5 分钟窗口;并搭配 (order_no, status) 幂等。 | 商户 |
| 对入站请求的主体篡改 | 每笔请求都必须带 X-Signature;verify_md5_signature 会在任何业务逻辑之前执行。 | Payment Gateway |
| 对入站回调的主体篡改 | 你的回调处理器在套用任何副作用之前先验证 X-Signature。 | 商户 |
| 对 API 的 IP 伪造 | 来源 IP 由 Cloudflare 边缘(WAF / Access)统一管控。 | Payment Gateway |
凭证外泄(api_key) | 可依需求提供轮换程序;你需把密钥存在 Secret Manager(密码管理工具)中并在日志中脱敏。 | 双方 |
| DNS 污染/MITM 攻击 | 端对端强制 TLS 1.2+;网关在生产环境中拒绝 HTTP 回调 URL 与商户端自签凭证。 | 双方 |
| 暴力破解凭证猜测 | Cloudflare 在边缘对 API 主机名套用速率限制;你也应对自身的认证失败做速率限制。 | 双方 |
| 日志文件遭窃导致凭证外泄 | 下方记录脱敏规范;由你在日志器中实作。 | 商户 |
| 并发重放与你的处理器抢跑 | 以 (order_no, status) 做幂等键;在套用副作用前先去重。 | 商户 |
清单上没有任何一项是纯粹网关的责任。即使由 Payment Gateway 负责的控制项,商户端也有对应的责任不可省略。
凭证生命周期
发放
你的 api_key 在开通阶段由客户经理配发。你会拿到两把密钥:一把沙盒、一把生产环境。两者彼此独立——沙盒密钥无法用于生产环境,反之亦然。密钥是由平台端通过 secrets.token_hex(16) 产生的 32 字元 hex token,并通过外部渠道交付。
轮换触发时机
下列任一情况发生时立即轮换:
- 密钥已知或疑似外泄(提交到源代码控制、贴进工单、以明文记录、通过不受信任渠道传送)。
- 持有密钥存取权限的员工离职或调任至无需知情的职位。
- 持有密钥的供应商或整合商结束合作。
- 排程轮换周期到期。建议最低周期为每季一次。
如何轮换
轮换由运维人员协作。联络你的客户经理。流程如下:
- 客户经理在你的商户记录上新增第二把有效密钥。
- 你部署新密钥并与旧密钥并存,验证流量正常。
- 客户经理在短暂的重叠窗口(约 5 分钟)后撤销旧密钥,期间两把密钥皆可接受,允许热切换而不中断请求。
目前商户后台不提供自助轮换。请在你的事件响应手册中为运维人员交接预留流程。
储存
api_key 等同于长期使用的密码凭证。请依此规格储存:
- 使用 Secret Manager(密码管理工具):AWS Secrets Manager、GCP Secret Manager、HashiCorp Vault 或同等方案。
- 在执行时从 Secret Manager 注入应用程序的程序环境或内存。
- 绝对不要提交到源代码控制,即使加密过也不行。Secret Manager 端的静态加密不等同于「在 repo 中加密」——后者可从任何历史 clone 还原。
- 绝对不要烘焙进容器映像;映像层会泄漏。
- 绝对不要包进客户端程式码、行动 App 或任何送到浏览器的东西。商户 API 接口仅限服务器对服务器。
团队内部分发
把生产环境 api_key 的知情范围限制在运维与待命工程师。在平台支援的情境下使用即时授权。稽核每一次对该机密的直接读取。将「谁知道生产环境密钥」清单视为需要与轮换同样周期审视的项目。
传输
出站(你的服务 → Payment Gateway)
所有商户 API 主机名只接受 HTTPS。网关在边缘终止 TLS 并拒绝明文 HTTP。可接受的 TLS 最低版本为 TLS 1.2;TLS 1.0、TLS 1.1 与 SSLv3 不会被协商。将你的 client 锁定为 TLS 1.2 或以上;不要设置回退到较旧套件。
入站(Payment Gateway → 你的回调 URL)
- 生产环境强制 HTTPS。
callback_url若不以https://开头,会在建单时被拒。没有 opt-out 选项。 - **必须提供有效的凭证链。**网关会以公开 CA bundle 验证你的 TLS 凭证。自签凭证会被拒绝(
webhook_ssl_verify = True为预设)。请使用任何公开 CA 签发的真实凭证。 - **你端也要 TLS 1.2+。**停用回调端点的 TLS 1.0 与 1.1。网关不会与其协商。
加密套件与现代安全规范
停用 export 加密、RC4、3DES 与任何标记为「EXPORT」或「anonymous」的套件。启用前向安全(ECDHE)。任何当代 TLS 函式库的现代预设值都是正确的。
网络层存取控制
商户 API 的入站请求会在 Cloudflare 边缘(WAF / Access)通过来源 IP 允许清单的检查。完整模型与其失败模式请见沙盒。生产环境同样套用此边缘管控。把自己从 Cloudflare Access 允许清单移除,是「昨天还能用」工单最常见的单一原因。
请求完整性
签名演算法
每笔入站商户请求与每笔出站回调都会以 MD5(sorted_params + api_key) 签名,输出为小写 hex。完整的逐步演算法与四种参考实作见认证与签名。本章不再重新推导。
此处选用 MD5 反映支付通道的业界惯例;它被当作搭配共享密钥的 MAC 风格完整性检查使用,而不是抗碰撞杂凑。由于 api_key 只串接但从不在传输中送出,即使攻击者拥有任意 MD5 碰撞能力,没有密钥也无法伪造签名。
未来可能会宣布迁移到 HMAC-SHA256。届时会在变更纪录中公告,附稳定的过渡计画、并行接受窗口与弃用日期。不要预先重新实作;契约以本文件所载为准。
时间戳窗口
X-Timestamp 为 Unix epoch 秒数。服务器端窗口为网关时钟的 ±5 分钟(300 秒),在签名验证之前强制执行。超出窗口则响应 401 INVALID_TIMESTAMP(见错误)。
在你的回调处理器中镜像这项检查。网关在每次派送与重试时都会送出新的 X-Timestamp;拒绝任何时间戳与你的时钟相差超过 5 分钟的回调。把你的时钟同步到 NTP——不要靠信任网关送来的时间戳来「修正」时钟漂移。
常数时间比较
验证入站回调签名时,使用常数时间相等函式:
- Python:
hmac.compare_digest(expected, received) - PHP:
hash_equals($expected, $received) - Node.js:
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received)) - Go:
subtle.ConstantTimeCompare([]byte(expected), []byte(received)) == 1
一般字串比较(==)会透过时间变化泄漏首个不同位元组的位置。样本累积足够后,攻击者可逐位元组推回伪造签名。网关在服务器端使用 hmac.compare_digest;你这端也应采用相同方式。认证章节随附的 verify 范例在各语言中皆使用正确原语。
时间戳窗口内的重放
有效签名加新鲜的时间戳,本身并不能防止 5 分钟窗口内的重放。拦截到回调的网络攻击者可在 5 分钟内重放。能击败此问题的控制是商户端以 (order_no, status) 做幂等——在下一节说明。签名加时间戳窗口缩小了重放机会;幂等则彻底化解。
把幂等当成安全属性
合法网络重试所产生的重复回调(网关对同一笔 NotificationLog 列最多再派送 5 次)与恶意重放尝试,在应用层是无法区分的。两者送达的都是相同主体上的合法签名。两者皆由同一控制处理:在套用任何副作用之前,以 (order_no, status) 去重。
这就是为什么回调建议在 (order_no, status) 上使用主键资料表,并采取 insert-or-no-op 的处理器。从安全角度看,那张资料表是你的重放防御层,不只是重试正确性层。
为什么不用其他键:
notification_id——回调主体中并未提供 per-attempt 事件 id。网关在所有重试中重用同一笔NotificationLog列。X-Timestamp——每次重试都会变动,因此以它去重会让同一事件被合法套用 5 次(合法情境)以及任意次数(恶意情境)。(order_no, status)——跨重试稳定,跨合法状态转移又有区别(例如MATCHED后COMPLETED两者都是真实事件、都该处理)。
日志规范
当机密以明文记录时,泄漏的日志文件就等同于泄漏的凭证。下列纪律请在日志器层落实——而不是呼叫端,否则会被跳过。
该记录的(搭配脱敏)
- HTTP method、请求路径、响应状态。
- 响应信封中的
code(见错误)。 - 你自己的
merchant_order_no与网关的order_no。 - 回调中的
notification_type/event字段。 - 签名验证结果的布林值(
pass或fail)。 - 你内部的 trace ID 或 request ID 以利关联。
X-Timestamp值以及你观测到相对于本机时钟的偏移。
不该记录的(等同凭证或属于 PII)
- **完整的
api_key。**只记录前 8 个字元加省略号:pre_xxxxxxxx...。绝不记录末尾位元组;绝不把整串接到 query string 里。 - **完整的
X-Signature值。**只记录验证通过或失败。Hex 值没有除错价值——若它是错的,你也无法从日志反推应有的值。 - **完整的
bank_card_number。**遮蔽除末 4 码之外的所有位元组。 - **完整的
account_no。**遮蔽除末 4 码之外的所有位元组。 - **PII 组合。**收款人姓名 + 银行 + 帐号合起来就能对应到某银行的真实人员。请脱敏、省略,或在独立存取控制下加密储存。
- **签名验证前的原始请求主体。**尚未验证的主体属攻击者可控内容。只在
X-Signature通过后,并套用上述脱敏,才记录它。
日志的存放位置
- 静态加密。多数受管理日志储存预设会这么做——确认你的也是。
- 对日志储存实施存取控制:读取权是特权角色,而非开发者预设。
- 保留期:合理预设为 热存 90 天、冷存 1 年。依你所在的法规环境调整。
- 备份沿用同样的控制。泄漏的备份就是泄漏的日志。
SIEM 整合
将下列项目作为安全事件转发到你的 SIEM:
- 来自网关的
401响应(AUTHENTICATION_ERROR、INVALID_SIGNATURE、INVALID_TIMESTAMP)。 - 来自网关的
403响应(AUTHORIZATION_ERROR)。 - 你的回调处理器上的签名验证失败。
- 任何持续对上述项目产生大量爆发的来源 IP。
在一段干净期间后出现 401 模式通常代表时钟漂移或部分密钥轮换。来源 IP 漂移则改在 Cloudflare 边缘呈现(TLS 握手失败或 HTML 挑战/封锁页,而非 JSON 403);JSON 403 现在代表商户停用或缺少权限。两者都是运维上的紧急事件。
常见陷阱
下列错误皆已在真实商户整合中上到生产环境。每一项都附带你会看到的错误代码或症状。
- **在任何地方记录完整
api_key。**Debug 日志、例外回报器、应用程序指标、错误追踪 SaaS 的 payload。只记录前 8 个字元加省略号。任何日志列中出现的api_key子字串都视为凭证外泄并启动轮换。 - **记录
X-Signature值。**该值没有除错用途。只记录布林 pass/fail。被记录的签名配上被记录的主体,会泄漏 chosen-message 攻击的输入。 - **生产环境使用 HTTP 的
callback_url。**建单时即被拒。你会看到400 VALIDATION_ERROR或相当消息(见错误)。请使用 HTTPS。 - **生产环境的回调端点使用自签凭证。**网关会拒绝连线(
webhook_ssl_verify = True)。请使用公开 CA。 - **跨沙盒与生产环境重用同一把
api_key。**设计上不可能(沙盒密钥会被生产环境拒绝、反之亦然),但若重用处理模式——例如同一个 Secret Manager 条目、同一个日志器、同一条事件路径——就会把两个环境耦合。请从头到尾保持分离。 - **把
api_key存在源代码控制里。**即使加密、即使用git-crypt、即使在私有 repo。加密层在源代码控制层之下游;历史 clone 会跨越轮换幸存。 - **跳过「内部」回调的签名验证。**沙盒回调由网关以与生产环境相同的演算法签署。如果你在沙盒跳过验证,会把那段程式码路径送到生产环境。两边都要验证。见回调。
- 以
message文字而非code做分支判断。message为资讯性质、措辞可能变动。code才是稳定契约。见错误。 - **在 client 端把 5 分钟时间戳窗口写死。**该窗口是服务器端政策。若网关把它收紧,以
now() - 4m59s签名的 client 会开始失败。用now()签名,把窗口判断交给服务器。 - **验证签名时忘记使用常数时间比较。**字串相等会泄漏。依语言使用
hmac.compare_digest、hash_equals或crypto.timingSafeEqual。 - **Cloudflare Access 允许清单漂移。**你的 NAT 出口 IP 变动(新资料中心、新 CI runner pool、云端供应商 IP 刷新)但 Cloudflare Access 允许清单没跟上。来源 IP 管控只在 Cloudflare 边缘执行,因此被挡的征兆是 TLS 握手失败或 HTML 挑战/封锁页,而不是带
AUTHORIZATION_ERROR的 JSON 403。请把出口 IP 同步到 Cloudflare Access 允许清单。见沙盒——生产环境套用相同模型。 - **在回调持久化层以未加密形式储存 PII(
bank_card_number、account_no、收款人姓名)。**静态加密不等同于资料库字段本身的加密。如果你的运维人员或唯读副本能看到明文,你的稽核姿态就错了。 - 未验证链路就信任 CDN 边缘标头。
X-Forwarded-For会被链路中每个 proxy 附加。清单中第一个 IP 是直接 client 写的;只有最右侧、由你自己的可信 proxy 附加的 IP 才有权威性。请以可信跳数计算来验证,而不是信任最左边的值。 - **持有密钥存取权的员工离职后忘记轮换
api_key。**轮换是离职流程的一部分,不是季度后事。离职手册必须含此步骤。 - **捕捉签名验证例外后继续执行。**在 verify 呼叫外套 try/except 而吞掉失败、继续处理主体,就是权限提升攻击向量。回
401并停止。见回调。 - **在验证前记录原始请求主体。**攻击者可控的内容会落入你的日志储存,包含可能与日志脱敏规则模式匹配的任意 JSON key,让机密溜走。先验证,再记录。
上线安全检查清单
把生产环境部署切到 api.open-pay.co 之前,逐项确认:
- [ ] 回调 URL 为 HTTPS 且凭证链可被公开 CA 验证。
- [ ]
api_key存放在 Secret Manager 中,不在 repo 的 env 文件、不在映像层、不在客户端程式码。 - [ ] **每个回调处理器的请求路径上都有签名验证。**不藏在 feature flag 后面、不在「内部」环境略过、不会在带 debug 标头时被绕过。
- [ ] 以
(order_no, status)为键的幂等资料表或快取已部署,并以重复回调情境测试过。 - [ ] **日志脱敏已测试。**用 grep 在 staging 日志中搜寻
api_key=pre_、X-Signature:与其他凭证样式。grep 应回传零笔。 - [ ] **Cloudflare Access 允许清单与你的生产环境 NAT 出口 IP 对齐。**有文件、有负责人、每季审视。
- [ ] SIEM 收到认证失败事件:来自网关的 401/403,以及你处理器上的签名验证失败。
- [ ] **密钥轮换周期已与客户经理协定。**最低每季一次。轮换手册已写成,并由作者以外的人读过。
- [ ] **事件响应手册涵盖
api_key外泄情境。**该打给谁、要求什么、重叠窗口多长、如何确认旧密钥已失效。 - [ ] **沙盒与生产环境凭证在部署设置中明确分离。**不同的 Secret Manager 路径、不同的环境变量、不同的日志前缀。没有在生产环境未设时 fall-through 到沙盒的预设值。
- [ ] **每台签署请求或验证回调的主机都设置了时钟同步(NTP)。**漂移超过 5 分钟是硬性失败。
- [ ] **双向皆强制 TLS 1.2+。**不回退至 1.0/1.1。