接口签名总对不上:%20 变 +、漏编码 !'()*、键序被 locale 改过
同一份参数,前端 JS 算出来能过,后端 PHP/Go 复算就是不一致——九成不是算法选错,是"待签名串"本身不同。MD5/HMAC-SHA256 对字节敏感,签名串差一个字节,结果完全没法比。
所以排查顺序是:先打印两端的原始签名串,逐字节比对;确认一致之后再怀疑密钥、时间戳、算法。下面这些差异,按踩到的频率排。
差异一:空格被编码成 %20 还是 +
application/x-www-form-urlencoded 规定空格编码为 +,RFC 3986 规定为 %20。不同语言默认各站一边:
| 实现 | 空格 | 说明 |
|---|---|---|
JS encodeURIComponent(' ') | %20 | RFC 3986 |
PHP rawurlencode(' ') | %20 | RFC 3986 |
PHP urlencode(' ') / http_build_query() | + | RFC 1738 |
Java URLEncoder.encode() | + | 表单编码 |
Go url.QueryEscape() / url.Values.Encode() | + | 表单编码 |
Python urllib.parse.quote(' ') | %20 | 默认 safe='/' |
PHP 的 http_build_query 默认走 RFC 1738,要换成 PHP_QUERY_RFC3986:
$q = http_build_query($params, '', '&', PHP_QUERY_RFC3986);
Go 要自己转:
func queryEscapeRFC3986(s string) string {
return strings.ReplaceAll(url.QueryEscape(s), "+", "%20")
}
只有当参数值里真的出现空格才会暴露,所以很多测试用例全过,上线碰到商品名带空格就挂。
差异二:encodeURIComponent 不编码 ! ' ( ) *
JS 的 encodeURIComponent 保留 A-Z a-z 0-9 - _ . ! ~ * ' ( )。RFC 3986 的 unreserved 只有 A-Z a-z 0-9 - _ . ~。也就是说,! ' ( ) * 五个字符在 JS 侧是原样输出的,在严格的 RFC 3986 实现里应该变成 %21 %27 %28 %29 %2A。
签名参数里出现这几个字符的概率不低:金额 1,000、备注 hello!、带括号的订单描述。只要一端编一端不编,签名串就不同。
反过来也有坑:有些 SDK 用 encodeURI(不是 encodeURIComponent),它连 : / ? # & = 都不编码,把整个参数区结构都留下了。
差异三:quote 的默认 safe
Python 的 urllib.parse.quote 默认 safe='/',斜杠不编码。参数值是 URL 或 base64 时立刻出问题:
from urllib.parse import quote
quote("a/b") # 'a/b'
quote("a/b", safe='') # 'a%2Fb'
Go 的 url.PathEscape 也不适合做参数编码,它保留 $ & + , ; = : @ 这些字符。
差异四:排序不是"按字母"
主流约定是 key 按 ASCII 升序拼接。但下面的写法会把它变成另一种顺序:
- PHP
ksort($arr, SORT_STRING)是按字符串比,含数字 key 时和SORT_REGULAR结果不同; - Java
String.compareTo比的是 UTF-16 code unit,遇到 emoji(代理对)和部分生僻字,顺序会与按 UTF-8 字节序不同; - 用了 locale 相关排序(比如 C 的
strcoll、shell 的sort在某些语言环境)会按本地语言规则排; - 有 SDK 先
strtolower(key)再排序,你没做,大小写混排的 key 就错位。
固定做法:统一 UTF-8 字节序、明确是否大小写归一、不依赖 locale。签名参数最好只用小写 ASCII key。
差异五:空值和 null 到底参不参与
三种写法结论不同:
key=(空字符串参与)- 直接剔除该 key
- 传
null被序列化成字符串"null"
JS 还有一个静默丢失:JSON.stringify 会把 undefined 的字段直接丢掉。如果先用对象拼签名串,某个字段是 undefined,这个 key 在签名串里就消失了,而服务端拿到的是没这个字段——两边恰好一致还算运气好;只要一边用了 Object.assign 补默认值,就错。
约定写进接口文档:只签非空字符串,空值一律剔除,数组单独规则。然后两端共用一个"构建签名串"的函数,别各写一套。
差异六:数组和重复键
a=1&a=2 和 a[]=1&a[]=2 是两种完全不同的串,而且 [] 会被编码成 %5B%5D。同一个参数名出现两次时,拼接顺序也会影响结果,签名前必须排序或明确按数组下标展开。
差异七:body 用原始串还是重新序列化
这条最隐蔽。以微信支付 APIv3 为例,签名串是五行:
HTTP方法\n
URL(path + query)\n
时间戳\n
随机串\n
请求体\n
第五行是"实际发出去的字节"。如果先算签名、再把 JSON 反序列化后重新序列化发出去,键序或空格一改,服务端算出来的签名就对不上。
同理,反向验签时必须拿原始 body 算,不能用已经解析过的结构体重新 marshal —— Go 的 map 序列化顺序、PHP 的 json_encode 空格设置、Python 的 ensure_ascii,都会改字节。
差异八:输出编码
- hex 大小写:MD5/HMAC 输出统一转小写再比,别拿大写 hex 和小写 hex 直接比对;
- HMAC 结果有时要求 base64,注意 URL-safe base64 的
-_替换和 padding; - Python
base64.encodebytes每 76 字符插换行,签名用base64.b64encode; - 有的接口要求签名前对参数做二次 URL 编码,有的不允许,文档里通常只写一句"参数需 URL 编码",实际指哪一层要看示例值。
排查清单
- 两端各打一份签名原文(脱敏:只留 key 与前 8 位),逐字节比对,先定位第一个不同位置。
- 拿最小参数集复现:1 个参数、纯数字值,确认算法与密钥没问题;再逐步加特殊字符。
- 用对方文档里的示例参数算一遍,与文档给出的签名值对齐。对齐不了就别往下调业务。
- 检查请求体:抓一次实际发出的 body,与参与签名的 body 做 byte 级 diff。
前提与不适用
上述内容针对"共享密钥 + 参数拼接"的对称签名(MD5、HMAC-SHA256 等)。RSA/SM2 这类非对称签名,除了签名串规则,还要看摘要算法、签名值编码(hex 还是 base64)、是否先做一次 base64。这一层各平台差异更大,务必以对应平台文档给出的示例向量为准。
另外,服务端如果对 signature 做了缓存,或白名单在压测环境放行,也会造成"线上必错、测试必过"的错觉,排查时先确认走的不是旁路。
文中的语言默认行为(http_build_query、URLEncoder、QueryEscape、quote 的 safe)按各自官方文档描述,未逐一实测所有运行版本的小版本差异,跨版本升级后建议重跑一次示例向量。