给 API 加签名防伪造,第一直觉通常是这么写:
signature = sha256(secretKey + requestBody)
能用吗?大部分场景下碰巧能用,但这个写法在密码学上是错的,而且错得很有意思——用 SHA-256 这类主流哈希函数拼前缀,攻击者不需要知道密钥,就能给任意新消息算出合法签名。这就是著名的长度扩展攻击(Length Extension Attack)。
HMAC 的整个设计,就是在回答"密钥和消息到底该怎么组合才安全"这个问题。1996 年 Bellare、Canetti 和 Krawczyk 三个人给出构造并给了安全证明,RFC 2104 固化成标准,从 TLS 到 JWT 用到了今天。
先看朴素的拼接为什么错
哈希函数不像加密,它没有密钥的概念,输入什么算什么。所以"加密钥"只能靠拼接,而拼接只有前缀、后缀两种拼法,两种都有各自的攻击。
前缀拼接 H(key ∥ msg):长度扩展攻击。
MD5、SHA-1、SHA-256 都属于 Merkle–Damgård 结构:把输入切成固定大小的块,逐块喂进压缩函数,上一块的输出(内部状态)作为下一块的输入,最后把内部状态直接作为哈希值输出。
关键就在"最后把内部状态直接输出"这一步。这意味着:哈希值本身就是算到某个位置时的内部状态。攻击流程是这样的:
- 攻击者拿到一条合法请求
msg和它的签名H(secret ∥ msg); - 根据
secret长度(可暴力枚举)和msg,推算出 padding——Merkle–Damgård 的填充规则是公开固定的,密钥长度枚举一下就覆盖了; - 从签名义恢复出内部状态,继续把
∥ 攻击者附加的数据喂进压缩函数; - 得到的值,恰好等于
H(secret ∥ msg ∥ padding ∥ 附加数据)——一个从未见过密钥的人算出了新的合法签名。
具体到接口上:请求里带 user=admin&amount=100,攻击者在其后追加 &admin=true 并配上续算出的签名,服务端校验直接通过。2011 年 Flickr 的 API 签名漏洞就是这么打的。
后缀拼接 H(msg ∥ key):躲开了长度扩展,但栽在碰撞上。
如果攻击者能找到两个不同的消息 m1、m2,使 H(m1) = H(m2),那么对任意后缀(包括密钥),H(m1 ∥ key) = H(m2 ∥ key)。因为内部状态一旦相同,后面喂什么都会同步走。找碰撞靠生日攻击,MD5 已经可以实际构造,SHA-1 也已被打穿。这个攻击的前提是攻击者得提前准备碰撞对,门槛比长度扩展高,但构造是成立的。
HMAC 的构造:两次哈希,两个异或
HMAC 的完整定义长这样:
HMAC(K, m) = H( (K ⊕ opad) ∥ H( (K ⊕ ipad) ∥ m ) )
其中 ipad 是 0x36 重复填充到块长度,opad 是 0x5c 重复填充到块长度。SHA-256 的块长度是 64 字节。
拆开看每一层在干什么:
内层先处理消息。密钥异或 ipad 后拼在消息前面做哈希——看起来跟朴素前缀拼接差不多,长度扩展照样能续算出内层哈希。但没关系,内层结果马上会被外层"封箱"。
外层处理内层结果。密钥异或 opad(跟内层不同的另一个填充)后拼在内层哈希值前面再做一次哈希。外层的输入是攻击者无法控制的固定长度数据,续算在这里断掉:想给新消息算签名,必须重新走一遍内层,而内层需要真实密钥。
两个 pad 值的选取没有什么神秘数字学,0x36 和 0x5c 的汉明距离大(有 4 个 bit 位不同),让两把"派生密钥"差异足够明显,仅此而已。HMAC 的安全性不依赖这两个具体值,依赖的是"内外两层用了不同的密钥材料"这个结构。
几个实现细节值得注意:
密钥太长先哈希。密钥超过块长度(SHA-256 是 64 字节)时,标准规定先用哈希函数压一遍再用。所以传入 HMAC 的密钥长度是任意的,但实际参与异或的永远是块长度的密钥材料。
密钥太短也行,但别太短。几个字节的密钥照样能算,安全性取决于密钥熵。生产环境的签名密钥建议至少 32 字节随机值,不要拿"our-secret-key-2024"这种可猜测字符串凑数。
异或 padding 的密钥可以预计算。密钥固定的话,K ⊕ ipad 和 K ⊕ opad 各算一次缓存下来,每次签名只需两次哈希调用,性能敏感的路径完全用得起。
不用 HMAC 自己造,代价是什么
最容易忽略的风险是时序攻击。比较签名时如果直接写:
if (signature === expected) { ... } // 危险
字符串比较在第一个不匹配的字符处提前返回。攻击者逐字符试:改第一位,测响应时间;某次响应变慢了,说明第一位对了。一位一位磨下去,最终把完整签名试出来。防御方法是用恒定时间比较,两个哈希无论多像,耗时都一样:
// Node.js
const crypto = require("crypto");
crypto.timingSafeEqual(Buffer.from(sig1), Buffer.from(sig2));
// PHP
hash_equals($sig1, $sig2);
另一个常见错误是HMAC 和"加密"混为一谈。HMAC 只保证完整性和真实性——消息没被改过、确实是持有密钥的一方发出的。它不保密,payload 还是明文的。JWT 用的 HS256 就是 HMAC-SHA256:header 和 payload 任何人都能解开看,但改一个字节签名就对不上了。需要保密就把内容加密(JWE),别指望签名帮你藏内容。
还有一点值得想清楚:HMAC 是对称的,收发双方持有同一把密钥,接收方也能伪造发送方的签名。所以它能证明"消息来自持有密钥的人",但不能证明"来自特定的某个人"——需要不可抵赖性(别人无法冒充你签名)就得用 RSA/ECDSA 这类非对称签名。内部服务间通信用 HMAC 足够,面向用户承诺"这个请求一定是你本人发的"就不合适了。
你每天都在用 HMAC,只是没注意
JWT 的 HS256。base64url(header).base64url(payload) 的部分作为消息,用服务端密钥做 HMAC-SHA256,结果作为第三段 signature。想验证 JWT 的签名结构 可以直接拿一个 token 到解码工具里看。
GitHub / Stripe 的 Webhook。请求头里的 X-Hub-Signature-256 就是 hmac_sha256(webhook_secret, body) 的十六进制表示。服务端收到后用同样的密钥重算一遍并做恒定时间比较。你自己做回调接口时,这就是标准模板。
两步验证(TOTP)。手机上的验证器每 30 秒变一个 6 位数字,核心算法就是 HMAC-SHA1(共享密钥, 当前时间 ÷ 30),再经动态截断取模成 6 位数。RFC 6238 全文就干这一件事。
AWS 的请求签名。Signature V4 把_access key、日期、区域、服务名串起来做了一串嵌套的 HMAC(HMAC 里面再套 HMAC,套四层),最后得到请求签名。比单层 HMAC 更工程化,但每一层都是同一个原语。
API 网关的请求签名。常见的做法是把 HTTP 方法、路径、时间戳、body 哈希按规则拼成 string-to-sign,再做 HMAC。时间戳进签名的目的就是防重放:服务端拒绝超过几分钟的老签名。
需要动手验证签名,可以用 在线 HMAC 计算工具 指定密钥和消息快速比对结果,配合 SHA 哈希工具 还能对照"裸哈希"与"HMAC"的差异——同一个输入,两个结果毫无关系,这正是那个异或 pad 在起作用。
一句话收尾:哈希负责防篡改,密钥负责防伪造,HMAC 是把这两件事安全地焊在一起的唯一标准做法。下次想自己拼密钥的时候,把这行 HMAC(K, m) 抄上去就好。
评论