一、HMAC 解决什么问题
普通哈希能证明「内容没变」,但任何人都能重新算一遍——攻击者改了内容,自己算个新哈希附上就行。HMAC(Hash-based Message Authentication Code) 引入一个只有双方知道的密钥:
HMAC(K, M) = H( (K' ⊕ opad) || H( (K' ⊕ ipad) || M ) )
看不懂公式没关系,记住三点:
- 没有密钥就算不出正确摘要,所以能证明「这条消息来自持有密钥的一方」。
- 输出长度与底层哈希一致(HMAC-SHA256 就是 32 字节)。
- 它同时提供完整性和身份认证,但不提供加密——内容仍是明文。
二、为什么不能自己拼 hash(key + msg)
这种写法叫「前缀密钥哈希」,存在长度扩展攻击:已知 H(key || msg) 和 key 的长度,攻击者可以在不知道 key 的情况下,构造出 H(key || msg || padding || 追加内容) 的合法值。因为 Merkle–Damgård 结构的哈希(MD5、SHA-1、SHA-256)可以从中途状态继续计算。
HMAC 用两层哈希(ipad/opad)把这个口子堵死。所以:要带密钥的摘要,用标准 HMAC,不要自己拼字符串。
三、接口签名的典型流程
- 双方约定一个
secret(通过安全渠道下发,不进前端代码)。 - 把业务参数按 key 字典序排序,拼成
k1=v1&k2=v2(空值是否参与要写进文档)。 - 加上时间戳
timestamp和随机串nonce。 - 对整串计算 HMAC-SHA256,输出十六进制或 Base64。
- 服务端用同样规则重算,比对一致且时间戳在允许窗口内(如 ±5 分钟)才放行。
import hmac, hashlib, base64
sig = base64.b64encode(
hmac.new(secret.encode(), msg.encode(), hashlib.sha256).digest()
).decode()
const crypto = require('crypto');
const sig = crypto.createHmac('sha256', secret).update(msg).digest('hex');
四、四个常见实现错误
- 拼接规则不一致:参数排序、空值处理、URL 编码方式任意一处不同,两边算出来就不一样。这是联调 401 的头号原因。
- 用
==比较签名:普通字符串比较会在第一个不同字节处提前返回,理论上可被时序攻击逐字节猜。应使用常量时间比较(Node 的crypto.timingSafeEqual、Python 的hmac.compare_digest)。 - 没防重放:只有签名没有时间戳/随机串,攻击者截获请求可以无限重放。必须加
timestamp+nonce并在服务端做去重。 - 密钥泄露在前端:把 secret 写进 JS,等于公开。浏览器端只能做「无密钥」的签名,或者由后端代签。
五、HMAC 与其它机制的区别
| 机制 | 密钥 | 用途 |
|---|---|---|
| 普通哈希 | 无 | 校验完整性(无攻击者场景) |
| HMAC | 对称密钥 | 接口签名、Webhook 校验 |
| RSA/ECDSA 签名 | 私钥签、公钥验 | 多方场景,验签方不需持有签名密钥 |
| AES-GCM | 对称密钥 | 加密 + 完整性一体 |
Webhook 场景(如支付回调)几乎都用 HMAC:提供方给一个 secret,接收方对原始请求体算 HMAC 并比对。注意要用原始字节算,不要先 JSON 解析再序列化——顺序和空白变了,签名就对不上。
六、调试方法
签名对不上时,把「待签名字符串」原样打印出来,两边对照,通常一眼就能看出少了个参数或编码不同。用 HMAC 生成工具 可以快速验证某个 key+message 的期望输出,再回去比对代码。需要把二进制签名放进 JSON 或 URL 时,用 Base64 编解码 转换。
小结
HMAC = 带密钥的哈希,提供完整性与身份认证;不要自己拼 hash(key+msg);签名必须配时间戳和随机串防重放;比较要用常量时间函数。