签名验证
在对每次投递采取行动之前先进行验证。
算法
- 读取
X-PipAI-Timestamp和X-PipAI-Signature。 - 如果时间戳与当前时间相差超过 5 分钟,则拒绝(防重放保护)。
- 计算
HMAC-SHA256(webhook_secret, timestamp + "." + raw_body)。 - 使用常量时间比较,将十六进制编码的摘要与签名请求头进行比对。
示例(Python)
import hmac, hashlib, time
def verify(secret, timestamp, body, signature):
if abs(time.time() * 1000 - int(timestamp)) > 5 * 60 * 1000:
return False
expected = hmac.new(secret.encode(), f"{timestamp}.{body}".encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
示例(Node.js)
const crypto = require('crypto');
function verifyWebhook(secret, timestamp, body, signature) {
if (Math.abs(Date.now() - parseInt(timestamp, 10)) > 5 * 60 * 1000) {
return false;
}
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${body}`)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature)
);
}
示例(Go)
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"strconv"
"time"
)
func VerifyWebhook(secret, timestamp, body, signature string) bool {
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return false
}
if abs(time.Now().UnixMilli()-ts) > 5*60*1000 {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
fmt.Fprintf(mac, "%s.%s", timestamp, body)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}
func abs(n int64) int64 { if n < 0 { return -n }; return n }
常见陷阱
- 对原始请求体签名,而不是解析后的副本。 不要相信
Content-Type而先 JSON 解码再重新编码后再计算 HMAC。即便是语义上等价的 JSON(不同的键顺序、不同的空白、转义与未转义的斜杠)也会产生不同的字节序列和不同的签名。请在任何中间件触碰之前先捕获原始请求体字节。 - 不要剥离或归一化空白。 对在线传输的精确原始请求体进行签名。修剪结尾换行、压缩内部空白或重新缩进都会破坏验证。
- 使用常量时间比较。 使用
hmac.compare_digest(Python)、crypto.timingSafeEqual(Node.js)或hmac.Equal(Go)。简单的==或字符串比较会泄露时序信息,攻击者有可能逐字节恢复出有效的签名。 - 先校验再处理——并以
400而非401拒绝。401会触发 PipAI 的重试逻辑,会反复重发同一个(仍然无效的)负载。对 签名失败请返回400 Bad Request,这样该投递会被记录为该事件的永久失败,不再被重试。
防重放保护
仅签名本身就能证明真实性——只有 PipAI 和你的端点知道 Webhook 密钥。5 分钟时间戳窗口提供了第二重保障:即使攻击者拦截到一个有效的已签名请求,也无法在 5 分钟之后重放它,因为你的校验器会以"已过期"为由拒绝该请求。
结合 HTTPS(首先就阻止了路径上的拦截)以及常量时间签名校验,就能获得强有力的端到端投递完整性。
为了多重保险——尤其考虑到 至少一次的投递保证——消费方还应保留一个最近见过的 event_id 滚动缓存,并拒绝重复项。24 小时 TTL 已经绰绰有余;PipAI 永远不会在该范围之外重试。这样既能为合法重试去重,也能挡住任何溜过时间戳检查的窗口内重放尝试。