跳到主要内容

签名验证

在对每次投递采取行动之前先进行验证。

算法

  1. 读取 X-PipAI-TimestampX-PipAI-Signature
  2. 如果时间戳与当前时间相差超过 5 分钟,则拒绝(防重放保护)。
  3. 计算 HMAC-SHA256(webhook_secret, timestamp + "." + raw_body)
  4. 使用常量时间比较,将十六进制编码的摘要与签名请求头进行比对。

示例(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 永远不会在该范围之外重试。这样既能为合法重试去重,也能挡住任何溜过时间戳检查的窗口内重放尝试。