2.3 签名校验:防篡改与防重放
场景:有人改了请求体里的金额
Api Key 解决了“你是谁”,但解决不了”请求被谁动过手”。出事是在一个周二下午:一家客户对接的支付回执接口被人抓包重放,同一笔回执被提交了十二次,财务差点重复记账。抓包分析发现请求头与参数被人改过,金额从 1 分被改成 100 元。好在下游做了幂等,但这件事逼着我们把签名校验提前到最高优先级。
签名校验要防两件事:篡改(改了内容能被发现)与重放(原封不动的包被反复提交)。前者靠摘要算法验证完整性,后者靠时间戳加 Nonce(一次性随机数)保证每个请求只能被消费一次。
方案:一张流程图与对比表
签名流程对客户是透明的:把请求的关键部分按固定规则拼接,用密钥算 HMAC 摘要,随请求一起发,网关用同样的规则验证。拼接规则的每个选择都有讲究:
| 设计点 | 我们的选择 | 备选与放弃原因 |
|---|---|---|
| 摘要算法 | HMAC-SHA256 | MD5,已不安全 |
| 签名内容 | 方法加路径加参数加时间戳加 Nonce | 仅签 body,改 query 绕过 |
| 密钥位置 | Header 之外的配置中心 | 随请求传,无意义 |
| 时间窗口 | 正负 5 分钟 | 无窗口,客户端时钟漂移直接挂 |
| 防重放存储 | Redis SETNX | 内存数组,多机不同步 |
防重放的具体机制:Nonce 随机字符串由客户端生成,网关收到后以 nonce:{appkey}:{nonce} 为 key 在 Redis 做计算,成功写入才算通过校验,已存在即判定重放拒绝。key 的过期时间等于时间窗口的两倍,过期自动清理。
配置:验签函数
验签函数写成纯函数,所有输入显式传入,出问题能单测、能回放。注意拼接字符串的每一项顺序全部固定死,文档里逐字写明:
<?php
// 签名校验核心(骨架)
function verifySignature(
string $appkey,
string $secret,
string $method,
string $path,
array $params,
string $bodySha256,
int $timestamp,
string $nonce,
string $sign
): bool {
// 时间窗口:防客户端时钟漂移
if (abs(time() - $timestamp) > 300) {
return false;
}
ksort($params); // 参数按 key 排序,拼接唯一
$payload = implode('&', [
$method,
$path,
http_build_query($params),
$bodySha256,
$timestamp,
$nonce,
]);
$expected = hash_hmac('sha256', $payload, $secret);
return hash_equals($expected, $sign);
}
两个细节值得强调:hash_equals 而非 === 比较摘要,避免计时侧信道攻击;body 先算 SHA256 再入签名拼接,大 body 不用反复算。参数加 body 双签名意味着改任何一个 query 参数或 body 字节都会让签名失效。
防重放的 Redis 逻辑:
<?php
// 一次性 Nonce:防重放
$ok = $redis->set(
"nonce:{$appkey}:{$nonce}",
1,
'EX', 600, // 十分钟过期
'NX' // 只在不存在时写入
);
if ($ok !== true) {
return $this->deny(40109, '请求不可重放');
}
对接降本:示例代码与自检工具
签名对接对客户是有学习成本的,这部分成本最终会变成我们的工单量。我们的做法是把成本前置消化:文档站为六种主流语言(PHP、Java、Go、Python、Node、C#)各维护一份官方 SDK 范例,每次签名规则有调整,六个范例同步更新并有 CI 跑冒烟测试。另外提供一个自检页面,客户粘贴自己的待签数据,页面返回拼接结果与期望签名,一秒定位拼接差异。
| 工具 | 形式 | 降低的工单类型 |
|---|---|---|
| 六语言官方范例 | 文档站代码 | 拼接规则咨询 |
| 签名自检页 | 在线小工具 | 时间戳与编码问题 |
| 时钟校对接口 | 返回服务端时间 | 时钟漂移排障 |
| 排查清单 | 一页表格 | 报 401 后不知问谁 |
这四样东西加起来开发不到三天,但据客服统计,签名类工单下降了七成。给客户的工具越到位,网关的运维就越清净。
降本工具里还要特别提一句自检页的设计:它不要求客户登录,不存储任何粘贴数据,页面只做本地计算。无状态、无留存的设计让客户技术同学敢于贴真实数据来排查,而不是小心翼翼编造假数据。工具的可用性往往不在功能多,在于让人放心用。
签名漏洞的攻防小故事也值得分享。上线半年后我们做了一次内部红蓝对抗,蓝队模拟攻击者。攻击者拿到了一次合法签名的完整请求后,试图重放与篡改:重放被 Nonce 机制挡死,改金额则签名不通过,改时间戳签名也不通过(因为时间戳在签名串里)。唯一的突破口是窗口内 replay 无法复现,蓝队最后无功而返。这次演练给团队带来了极大的信心,也让管理层同意把签名校验扩展到全部接口而非只是敏感接口,签名的成本从来不是计算耗时,而是接入复杂度,工具链跟上之后这成本就被摊薄了。
踩坑:客户端时钟漂移与空参数
第一个坑是时钟。签名上线第一周,两家客户的服务器时钟偏了八分钟,全部请求被判超时,我们不得不把窗口从两分钟放宽到五分钟并在文档站加了时钟校验小工具。对于 NTP 同步保证不了的环境,建议网关返回服务端时间头,客户端每次先对齐再签名。
第二个坑是空参数与编码。有的客户端 SDK 对空值参数干脆不传,有的传空字符串,两种在服务端拼出来的签名串不一样,验签必然失败。统一规范:所有声明参数一律参与签名,空值记为空字符串。PHP 的 http_build_query 与各语言 SDK 的 URL 编码规则不一致也踩过,最后在文档里直接锁定 RFC 3986 编码并给了六个语言的示例代码。
第三个坑是 GET 请求的 body 签名。有客户在 GET 请求里也带 body,网关最初忽略了 GET 的 body 验签,等于给篡改留了门。修复是所有方法一律算 body 摘要,GET 也一样。
第四个坑是 Nonce 与 Redis 压力。高并发下 Nonce 的写入量等于请求量,Redis 这条 key 虽然都短命,但写入 QPS 直接翻了一倍。优化方式是把 Nonce 的校验改为两级:进程内一个小的布隆过滤器先挡一遍已知 nonce,未命中再走 Redis 写入判定。注意布隆过滤器有假阳性,所以它命中只能作为二次确认信号去查 Redis,而不是直接拒绝。这个组合让 Redis 的 Nonce 写入量降了四成,而准确性没有任何损失。性能与正确的平衡,永远是中间件设计的主旋律。
另外提醒一个签名与限流的交互坑。限流中间件挡掉的请求同样要消耗 Nonce 判定,如果限流挡的量大,Nonce 存储会被打满。我们的做法是限流判定在签名之前先跑(先认出谁再谈配额),被限流的请求直接短路径返回,不写 Nonce,不动 Redis。顺序上的这一笔调整让 Redis 的写入量在限流触发时直降,是中间件顺序设计的又一个例证。记住这个口诀:先认人,再验签,最后治流量。
小结
防篡改靠 HMAC-SHA256 签名方法加路径加参数加 body 摘要加时间戳加 Nonce,防重放靠 Nonce 加 Redis 计算窗口。三个易翻车点:时钟窗口留余量、空值参数与编码规则在文档里钉死、摘要比较用恒定时间函数。签名校验做扎实,第 5.3 节的安全加固能省一半心。签名校验上线一年后,它甚至成了客户对我们的信任凭证。有新客户的技术负责人在选型阶段专门问起防重放机制细节,听完实现后当场拍板。安全投入从来不是纯成本,它是 To B 业务的销售资产。最后一句提醒:签名规则一旦发布就难改,客户代码已经在用,所以协议设计阶段多花一周评审,比上线后打补丁便宜得多。
再补一条协议演进的方法。签名规则后来确实需要升级(增加对 Header 某字段的签名以应对代理重写场景),我们的做法不是改老规则,而是开 v2 签名并行:契约里声明每个 appkey 用哪个版本,客户在自助后台一键切换并保留一个月回退窗口。两年完成迁移,零中断、零投诉。协议升级最优雅的方式是新老并行加客户自助切换,而不是一声令下全量改造。
