一、代码照抄文档,还是报签名错误
对接一个电商开放平台,文档里给了一段示例代码,复制过来改两个变量就能跑。
实际跑起来:sign check failed。
然后开始长达两小时的排查:确认 AppSecret 没抄错、确认时间戳没过期、确认参数名大小写、确认编码是 UTF-8……每一轮都觉得自己找到了,每一轮还是错。
这类问题的根源几乎从不在算法。MD5、HMAC、RSA 都是标准算法,任何语言的库都不会算错。错的是「参与签名的那串原文」——你拼出来的字符串和对方期望的,少了一个空格、多了一个参数、顺序差了一位,摘要就完全不同。
下面把三种签名方式拆开讲,重点放在拼串规则上。
二、三种签名方式的定位
| 方式 | 密钥关系 | 典型场景 | 强度 |
| MD5 拼串 | 双方共享同一个 secret | 国内老平台的主流方案 | 中(依赖 secret 保密) |
| HMAC-SHA256 | 双方共享同一个 key | 较新平台的推荐方案 | 较高(结构上抗长度扩展) |
| RSA2 | 调用方持私钥,平台持公钥 | 回调通知验签、开放平台授权 | 高(无需共享密钥) |
选型不是「越强越好」。对方给什么你就用什么,能改的只有实现质量。
三、MD5 拼串:七个必须对齐的细节
标准流程是四步:过滤 → 排序 → 拼接 → 加摘要。每一步都藏着坑。
import hashlib def sign_md5(params: dict, secret: str, upper: bool = True) -> str: """key 排序拼串签名。 规则:剔除空值与签名本身 → 按 key 字典序 → k=v 用 & 连接 → 末尾追加密钥 → MD5 """ filtered = { k: v for k, v in params.items() if k not in ("sign", "sign_type") and v not in (None, "") } pairs = ["%s=%s" % (k, filtered[k]) for k in sorted(filtered)] raw = "&".join(pairs) + "&key=" + secret digest = hashlib.md5(raw.encode("utf-8")).hexdigest() return digest.upper() if upper else digest
七个别对不齐的地方:
1. 空值到底参不参与。 这是最高频的分歧点。有的平台要求 v="" 也参与排序拼接,有的要求剔除。文档里通常只有一句「去掉空值」,而「空值」指 None、空字符串、还是字符串 "null",各家不同。
2. 排序是字节序还是字典序。 参数名含大小写混合时(如 appKey 与 AppId),Python 的 sorted() 按 ASCII 排,大写字母在小写之前。Java 的 TreeMap 默认也是按字符编码。但有的平台要求忽略大小写排序,那就得显式指定 key。
3. sign 字段自己参不参与。 显然不参与,但代码里忘了剔除是最常见的低级错误——尤其在你把整个请求体直接丢进签名函数的时候。
4. 密钥拼接的形式。 是 &key=secret,还是直接末尾追加 secret?看起来只差 5 个字符,但决定成败。这个必须逐字对照文档示例。
5. URL 编码的时机。 是先对每个 value 做 URL 编码再拼串,还是先拼串再编码?两者结果不同。正确顺序通常是:原始值拼串签名 → 发送时再编码。如果先编码,空格变成 %20,签名原文就变了。
6. 数组与嵌套对象的序列化。 {"ids": [1,2,3]} 拼成 ids=1,2,3 还是 ids=[1,2,3] 还是 ids=1&ids=2&ids=3?JSON body 的键序要不要固定?这一条没有统一规范,只能看对方示例。
7. 摘要大小写。 平台要 32 位大写,库默认输出小写。hexdigest().upper() 一行的事,但漏了就是两小时。
四、HMAC-SHA256:为什么它比拼串更安全
MD5 拼串有个结构性弱点:它把密钥当普通字符串拼进原文。如果拼法是 hash(secret + message),就存在长度扩展攻击——攻击者不需要知道 secret,只要知道 secret 长度和某个消息的哈希值,就能在消息后面追加内容并算出合法的哈希。
HMAC 用两次哈希(ipad / opad)把密钥和消息分离,从结构上避免了这个问题。
import hmac import hashlib def sign_hmac_sha256(params: dict, key: str) -> str: filtered = {k: v for k, v in params.items() if k != "sign" and v not in (None, "")} raw = "&".join("%s=%s" % (k, filtered[k]) for k in sorted(filtered)) return hmac.new(key.encode("utf-8"), raw.encode("utf-8"), hashlib.sha256).hexdigest()
注意拼串规则和 MD5 版完全一样(排序、过滤、连接符),变的只有最后一步的摘要方式。所以如果你已经把 MD5 版的拼串逻辑写对了,切换 HMAC 只需要改两行。
顺带说一句:MD5 本身在 2004 年就被证明存在实用碰撞。用它做签名的风险不是「密钥被破解」,而是「存在两条不同消息摘要相同」的可能。平台还在用就用,但你自己设计接口时没理由再选它。
五、RSA2:私钥签名,公钥验签
前两种是对称的,双方共享一个密钥。RSA2 是非对称的,解决了「密钥怎么安全地给对方」这个问题。
典型用法是平台回调你的服务时,用它的私钥签名,你用它的公钥验签。你不需要持有任何秘密,公钥可以公开。
import base64 from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import padding def sign_rsa2(raw: str, private_key_pem: bytes) -> str: key = serialization.load_pem_private_key(private_key_pem, password=None) sig = key.sign(raw.encode("utf-8"), padding.PKCS1v15(), hashes.SHA256()) return base64.b64encode(sig).decode() def verify_rsa2(raw: str, sign_b64: str, public_key_pem: bytes) -> bool: key = serialization.load_pem_public_key(public_key_pem) try: key.verify( base64.b64decode(sign_b64), raw.encode("utf-8"), padding.PKCS1v15(), hashes.SHA256(), ) return True except Exception: return False
SHA256withRSA 就是上面这两段。两个实战细节:
第一,被签名的原文必须先确定下来。 回调是 JSON body,签名通常是对原始 body 字符串做,不是对解析后的 dict 重新序列化。如果你解析完再 json.dumps,键序和空格都可能变,验签必失败。正确做法是:拿到原始 bytes → 验签 → 再解析。
第二,PKCS1v15 还是 PSS。 绝大多数平台用 PKCS1v15(也叫 RSA2),少数新接口用 PSS。padding 选错,报的还是「验签失败」,同一个错误信息。
六、一张对照表
| 细节 | MD5 | HMAC-SHA256 | RSA2 |
| 参与签名的原文 | 排序拼串 | 排序拼串 | 原始 body 字符串 |
| 密钥位置 | 拼进原文末尾 | 作为 HMAC key | 不在原文中 |
| 输出格式 | 32 位十六进制 | 64 位十六进制 | Base64 |
| 大小写要求 | 多数要求大写 | 多数要求小写 | 不适用 |
| 验签方 | 用同一 secret 重算比对 | 用同一 key 重算比对 | 用公钥验证 |
七、检查清单
- 空值(None / "" / "null")的处理方式与文档示例逐字核对过
- 排序规则(是否忽略大小写)已确认
sign、sign_type字段已从原文中剔除- 密钥拼接形式(
&key=还是直接追加)与示例一致 - URL 编码发生在签名之后,不是之前
- 数组、嵌套对象的序列化方式与示例一致
- 摘要大小写与平台要求一致
- 回调验签用的是原始 body 字节流,不是重新序列化的 JSON
- RSA 的 padding 模式(PKCS1v15 / PSS)选对
- 有一份「签名原文」的 debug 日志,出错时能直接比对
最后一条最有用。把签名前的 raw string 打进日志(脱敏后),下次报错时你只需要把它和文档示例对比一眼,而不是再猜两小时。
八、一句总结
签名这件事,算法是确定的,不确定的只有拼串规则。
所以真正该做的不是找一个「万能签名库」,而是把拼串逻辑单独抽成一个纯函数,输入是参数字典和密钥,输出是原文字符串,再单独做摘要。这样出错时你能一眼看出是原文不对还是摘要不对,而不是在一行链式调用里猜。