使用方法
- 选择算法。默认是 HMAC-SHA256,也是大多数 Webhook 和 API 使用的算法。
- 输入密钥,并把“密钥格式”设为文本(按 UTF-8 读取)、十六进制或 Base64。如果密钥是原始字节,而不是类似口令的字符串,请选择十六进制或 Base64。
- 输入或粘贴消息,也可以把输入格式切换为十六进制,或者打开文件。HMAC 会随输入实时更新。
- 选择小写十六进制、大写十六进制或 Base64 输出并复制结果;也可以把收到的签名粘贴到“对比”框中,查看是否一致。
HMAC 的工作原理
HMAC(基于哈希的消息认证码,RFC 2104)把密码学哈希函数与密钥结合在一起。只有知道密钥的人才能为某条消息算出正确的值,所以 HMAC 一致同时说明两件事:消息没有被改动,并且来自持有密钥的一方。
它的结构是 H((K ⊕ opad) ‖ H((K ⊕ ipad) ‖ message))。密钥如果比哈希函数的分组更长(MD5、SHA-1 和 SHA-256 为 64 字节,SHA-384 和 SHA-512 为 128 字节),会先被哈希一次,再用零填充到分组长度;然后与 ipad(重复的 0x36)异或,和消息一起计算内层哈希,再与 opad(重复的 0x5c)异或,和内层结果一起计算外层哈希。简单拼接的 SHA-256(key ‖ message) 没有外层哈希,任何人看到一个标签后,都能在消息末尾追加数据,并在不知道密钥的情况下算出新的有效标签,这就是长度扩展攻击。
HMAC 的输出长度与底层哈希相同,HMAC-SHA256 为 32 字节,即 64 个十六进制字符。RFC 2104 建议密钥不短于这个长度,可以用随机 Base64 生成器生成合适的密钥。
HMAC 签名的常见用途
- GitHub Webhook。
X-Hub-Signature-256请求头的值是sha256=加上原始请求体的 HMAC-SHA256(十六进制),密钥为 Webhook 密钥。可以把整个请求头的值粘贴到“对比”框中,sha256=前缀会被自动忽略。 - Stripe Webhook。
Stripe-Signature请求头包含时间戳t和签名v1。签名是对“时间戳、点号和原始请求体”计算的十六进制 HMAC-SHA256,密钥是按文本使用的端点密钥(whsec_...)。 - Shopify Webhook。
X-Shopify-Hmac-Sha256中是原始请求体 HMAC-SHA256 的 Base64 形式,密钥为应用的客户端密钥(client secret)。可以选择 Base64 输出,或直接把请求头的值粘贴到“对比”框中。 - AWS 签名版本 4。签名密钥通过链式 HMAC-SHA256 派生:先以
AWS4加秘密访问密钥为密钥对日期计算 HMAC,再依次对区域、服务和aws4_request计算,每一步都以上一步的结果为密钥。把密钥格式设为十六进制,就能把上一步的输出直接用作下一步的密钥。 - JSON Web Token。HS256、HS384 和 HS512 就是对“编码后的头部、点号和编码后的载荷”计算的 HMAC-SHA256、-384 和 -512。令牌的第三段是这个 HMAC 的 URL 安全 Base64 形式,可以原样粘贴到“对比”框中。完整的令牌可以用 JWT 解码工具验证。
务必基于收到的原始请求体计算 HMAC,不要做任何改动。把 JSON 解析后再重新序列化,会改变空格或键的顺序;哪怕只多了一个换行符,HMAC 也会完全不同。
在代码中计算 HMAC
# Python
import hmac, hashlib
sig = hmac.new(b'key', b'message', hashlib.sha256).hexdigest()
hmac.compare_digest(sig, received) # 恒定时间比较
// Node.js
const crypto = require('crypto');
const sig = crypto.createHmac('sha256', 'key').update('message').digest('hex');
// 长度不同时 timingSafeEqual 会抛出异常,请先检查长度
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(received));
// PHP:消息在前,密钥在后
$sig = hash_hmac('sha256', 'message', 'key');
hash_equals($sig, $received);
# OpenSSL:文本密钥,以及以十六进制给出的同一密钥
printf '%s' 'message' | openssl dgst -sha256 -hmac 'key'
printf '%s' 'message' | openssl dgst -sha256 -mac HMAC -macopt hexkey:6b6579
# 测试向量:密钥 "key",消息 "The quick brown fox jumps over the lazy dog"
# HMAC-SHA256 = f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8
在自己的代码中校验签名时,请使用上面这类恒定时间比较函数。普通的 == 遇到第一个不同的字符就会返回,攻击者可以利用这种时间差逐字节试出有效签名。对于 Stripe 这类带时间戳的方案,还应拒绝时间戳距今已超过几分钟的请求,以防重放攻击。
常见问题
HMAC-MD5 和 HMAC-SHA1 还安全吗?
碰撞攻击使 MD5 和 SHA-1 作为普通哈希不再安全,但这些攻击并不能实际用来伪造 HMAC,因为 HMAC 依赖的是哈希函数的其他特性以及密钥(RFC 6151 专门讨论了 HMAC-MD5)。必须使用它们的现有集成,例如基于 HMAC-SHA1 的 TOTP 动态验证码,可以继续使用;新的设计请选择 HMAC-SHA256。
为什么我算出的 HMAC 与 Webhook 签名不一致?
常见原因有:对解析后重新序列化的 JSON 而不是原始请求体进行计算;消息末尾多了换行符;密钥错误或密钥格式不对(例如把 Base64 或十六进制密钥当作文本输入,或者反过来);算法选错。十六进制与 Base64 的差别则不会造成问题:“对比”框同时接受大小写十六进制、标准 Base64 和 URL 安全 Base64。
HMAC 和普通哈希有什么区别?
HMAC 就是数字签名吗?
不是。HMAC 的双方共享同一个密钥,能验证标签的人也能生成标签。RSA、ECDSA 等数字签名(例如 JWT 中的 RS256、ES256)用私钥签名、用公钥验证,验证方无法伪造签名。
我的密钥会被发送到服务器吗?
不会。HMAC-SHA1 和 SHA-2 系列由浏览器的 Web Crypto API 计算,HMAC-MD5 由页面中的 JavaScript 计算,密钥和消息都不会离开你的设备。尽管如此,生产环境的密钥仍需谨慎对待,如果可能已经泄露,请及时更换。