在很多开放平台的签名文档里,会看到「使用 HMAC-SHA1 计算签名」这样的要求。既然 SHA-1 已经被实际攻破,为什么这里还在用它?要回答这个问题,需要先分清两件事:碰撞抗性*和*消息认证。HMAC 用到的正是后者,而 SHA-1 的弱点主要在前者。
裸哈希不能当签名用
先看一个常见的错误做法:把参数按顺序拼成一个字符串,算一次 SHA-1,把结果当作签名。服务端收到请求后用同样的方式重算一遍,比对是否一致。
这个方案有一个致命问题——攻击者也能算。因为拼接规则是公开的,攻击者拿到参数就能算出同样的摘要。更糟的是,拼接本身还可能引入歧义:("ab", "c") 和 ("a", "bc") 拼出来的字符串可能一样,导致不同的参数组合产生相同的签名。
裸哈希不提供任何身份证明,它只能证明「这段内容算出来是这个摘要」,不能证明「这段内容是谁发的」。
HMAC 引入了密钥
HMAC 的做法是在哈希函数外面套一层带密钥的结构。它不直接对消息算哈希,而是把密钥和消息通过两轮异或与哈希运算混合在一起:
HMAC(K, m) = H( (K ⊕ opad) || H( (K ⊕ ipad) || m ) )
其中 ipad 和 opad 是两个固定的填充常量。这个结构的价值在于:没有密钥就算不出结果。攻击者即使完全知道算法和消息,也无法伪造签名,因为他不知道 K。
于是 HMAC 提供的是「消息认证」:既确认消息没被改动,也确认消息来自持有密钥的一方。
为什么 SHA-1 的碰撞不直接影响 HMAC
这里回到最初的问题。
针对 SHA-1 的碰撞攻击,目标是找出两个消息 m1 和 m2,使 H(m1) = H(m2)。而 HMAC 的输出是 H((K ⊕ opad) || H((K ⊕ ipad) || m)),输入里含有攻击者不知道的密钥。
攻击者无法预测内部那一层哈希的结果,也就无法针对性地构造出外部哈希的碰撞。这就是为什么 HMAC-SHA1 的安全性与裸 SHA-1 不同,它被单独评估,并且至今没有被实际攻破。
这也是一个更普遍的规律:哈希函数的性质不能脱离用法来评价。同一个算法,用在裸摘要上是「已破」,用在 HMAC 结构里是「可用」,用在口令存储上是「不该用」——三种结论对应三种不同的攻击模型。
为什么新系统还是应该换掉
尽管 HMAC-SHA1 没有被攻破,新系统仍然建议直接用 HMAC-SHA256。原因不在当下的安全性,而在长期的余量和迁移成本。
第一,SHA-1 的输出是 160 位,HMAC 的输出长度跟随底层哈希,所以 HMAC-SHA1 的签名只有 160 位。这个长度在今天仍然够用,但余量不如 256 位充裕。
第二,SHA-1 的整体生态正在收缩。库和平台的默认值在变,未来某一天可能遇到「某个环境不再支持 SHA-1」的情况,那时被迫迁移的成本比现在主动迁移高。
第三,合规审查越来越倾向于拒绝 SHA-1 相关配置。即使技术上安全,通过审查时解释成本也不低。
还有一个坑:长度扩展攻击
顺带说一个与 HMAC 相关的话题。有一类设计是「H(密钥 || 消息)」,把密钥拼在消息前面再哈希。这种写法存在长度扩展攻击的风险:对某些迭代结构的哈希函数(包括 SHA-1、SHA-256),攻击者在已知 H(密钥 || 消息) 和消息长度的情况下,可以推算出 H(密钥 || 消息 || 填充 || 追加内容) 的结果,从而在没有密钥的情况下伪造出合法签名。
HMAC 的两轮结构正是为了抵御这类攻击而设计的。所以自定义签名方案时,不要用「密钥拼接哈希」的简化写法,直接用标准的 HMAC 实现。
检查清单
- 裸哈希不能当签名用,因为任何人都能算出同样的摘要
- HMAC 引入密钥,提供消息认证,能证明来源
- SHA-1 的碰撞攻击不直接适用于 HMAC-SHA1,两者应分开评估
- 新系统建议直接使用 HMAC-SHA256,理由在余量与迁移成本
- 不要用
H(密钥 || 消息)的简化写法,存在长度扩展攻击风险 - 自定义签名方案时优先使用标准库提供的 HMAC 实现