HMAC — 网络钩子如何知道你没有说谎
任何服务器都可以向您的 Webhook 端点发送 POST 请求。HMAC 签名是合法发送者证明其编写了数据负载的方式,也是您验证该数据负载的方式。
每次Stripe对信用卡收费时,都会触发一个Webhook。每次GitHub合并拉取请求时,也会触发一个Webhook。这些平台会将POST请求发送到你提供的URL上——但这里有一个令人不安的事实:任何人都可以向这个相同的URL发送POST请求。
那么你的服务器如何知道这个请求确实来自Stripe,而不是某个攻击者猜中了你的端点URL呢?答案是HMAC——一旦你理解了它,就会明白为什么它成为每个严肃API平台的标准做法。
问题:任何人都可以向你的端点发送POST请求
Webhook端点只是URL。它们是公开可访问的(必须如此,以便发送方能够到达它们),并且接受POST请求。没有任何机制阻止恶意用户构造一个虚假的负载并将其发送到你的端点。
想象一下,当你的Webhook处理器接收到一个事件时,它会这样做:
if event["type"] == "payment.completed":
fulfill_order(event["data"]["order_id"])
一个知道你端点URL的攻击者可以发送一个他们喜欢的订单ID的虚假 payment.completed 事件。如果没有验证,你的服务器会愉快地处理那些从未支付的订单。
你需要一种方法来验证该负载是由持有你们共享的密钥的人创建的——而无需在请求中传输该密钥。
什么是HMAC?
HMAC代表 基于哈希的消息认证码它是一种结合了加密哈希函数(通常为SHA-256)和一个密钥来生成签名的构造。该签名证明了两点:
- 真实性 —— 该消息是由持有密钥的人创建的
- 完整性 —— 该消息在传输过程中未被修改
HMAC的关键特性是:你无法在不知道密钥的情况下生成有效的签名。而且你无法通过签名反向恢复密钥。它是一种单向证明。
HMAC 与纯哈希的区别
纯哈希(如SHA-256)解决了完整性问题,但无法保证真实性。一个拦截到有效负载的攻击者可以重新计算一个被修改后负载的哈希值。而HMAC将你的密钥融入哈希过程的每一步,因此即使你知道所使用的哈希算法,只要没有密钥,就无法生成匹配的签名。
Webhook HMAC的工作原理
该流程包含三个步骤:密钥交换、签名和验证。
步骤1:密钥交换(仅发生一次)
当你配置一个Webhook(如Stripe或GitHub)时,它们会生成一个 Webhook密钥 并一次性显示给你。你将其存储在服务器端(绝不能存储在客户端代码或公开仓库中)。至此,密钥再也不会通过网络传输。
步骤2:发送方对负载进行签名
在发送Webhook之前,平台会使用你共享的密钥对原始请求体计算一个HMAC签名:
signature = HMAC-SHA256(secret_key, request_body)
签名随后附加到请求中,通常位于一个类似 X-Hub-Signature-256 (GitHub) 或 Stripe-Signature (Stripe) 的头部。原始负载以不变的形式存在于请求体中。
步骤3:你在接收时进行验证
当你的服务器接收到Webhook时,你使用原始体和你存储的密钥重新计算相同的HMAC,然后将结果与头部中的签名进行比较。如果匹配,则负载是真实且未被修改的;如果不匹配,则拒绝该请求。
expected = HMAC-SHA256(your_secret, raw_body)
if not constant_time_equal(expected, header_signature):
return 401
注意 恒定时间比较 ——我们稍后会解释为什么这很重要。
进行预览。
Stripe
Stripe发送一个 Stripe-Signature 一个包含时间戳和一个或多个签名的头部:
Stripe-Signature: t=1679000000,v1=abc123...,v0=oldformat...
签名是基于 timestamp.payload (与点号连接) 计算的。包含时间戳使Stripe能够防御重放攻击——如果攻击者捕获了一个有效的签名请求并稍后重新发送,你可以因为时间戳过旧而拒绝该请求。
GitHub
GitHub发送一个 X-Hub-Signature-256 头部,格式为 sha256=<hex_digest>。签名是使用你在仓库设置中配置的Webhook密钥对原始体进行HMAC-SHA256计算的结果。
Shopify
Shopify使用一个 X-Shopify-Hmac-Sha256 头部,包含一个Base64编码的HMAC-SHA256签名——概念相同,编码方式不同。
代码中的验证
以下是三种常见编程语言中验证的示例。模式是相同的——仅库调用不同。
Python
import hmac
import hashlib
def verify_webhook(secret: str, payload: bytes, signature_header: str) -> bool:
expected = hmac.new(
secret.encode(),
payload,
hashlib.sha256
).hexdigest()
received = signature_header.removeprefix("sha256=")
return hmac.compare_digest(expected, received)
Node.js
const crypto = require('crypto');
function verifyWebhook(secret, rawBody, signatureHeader) {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
const received = signatureHeader.replace('sha256=', '');
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(received)
);
}
PHP
function verifyWebhook(string $secret, string $rawBody, string $signatureHeader): bool {
$expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret);
return hash_equals($expected, $signatureHeader);
}
可能导致安全问题的错误
1. 使用 == 而不是恒定时间比较
标准字符串相等(==, ===)一旦发现不匹配就会短路。这会产生一个 时间侧信道:攻击者可以测量你的服务器拒绝不同签名所需的时间。共享较长前缀的字符串会稍微更长的拒绝时间。通过大量请求,攻击者可以利用这一点逐字节地重构一个有效的签名。
始终使用恒定时间比较: hmac.compare_digest() 在Python中, crypto.timingSafeEqual() 在Node.js中, hash_equals() 在PHP中。
2. 在验证前解析体
HMAC是基于 原始字节 计算的。如果你先解析JSON,然后再重新序列化以进行验证,可能会得到不同的字节序列(不同的键顺序、空白、编码)。始终在框架的体解析器触碰之前捕获原始体,然后基于该原始体进行验证。
3. 未检查重放攻击
一个有效的签名请求是永久有效的——除非你检查时间戳。如果平台在其签名方案中包含时间戳(Stripe有;GitHub没有),则拒绝时间戳比几分钟旧的请求。这可以防止攻击者捕获并重放一个合法请求。
4. 在源代码中硬编码密钥
Webhook密钥应存储在环境变量或密钥管理器中,绝不能提交到版本控制系统。密钥泄露意味着攻击者可以伪造任何负载,直到你轮换密钥为止。
HMAC无法防范的情况
HMAC证明负载是由持有密钥的人签名的。它 不是 无法防范:
- 发送方被攻陷 ——如果Stripe的签名基础设施被攻陷,虚假事件仍然会拥有有效的签名
- 重放攻击 ——除非你同时验证时间戳或nonce
- 机密性 ——HMAC不会加密任何内容;负载以明文形式传输(尽管HTTPS会处理这一点)
对于大多数Webhook集成,基于HTTPS的HMAC足以满足所有需求。
