验证回调签名
用登记地址时拿到的签名密钥,验证每个通知确实来自我们。
本页内容
每个通知都带 Chenvoya-Signature 请求头。先验证签名,再读取正文;验证失败时返回非 2xx 状态并丢弃这次请求。
签名格式
Chenvoya-Signature: t=1793167400,v1=<64 位小写十六进制>t 是签名时间(Unix 秒),v1 是小写十六进制的 HMAC-SHA256。
验证步骤
- 把登记时取得的
signing_secret做 base64url 解码,得到 32 字节的验签密钥。 - 从
Chenvoya-Signature拆出t和v1。 t与本地时间相差超过 5 分钟时拒绝。- 用验签密钥计算 HMAC-SHA256,输入是
chenvoya.v1.、t、一个点.,再接正文的原始字节。 - 把结果写成小写十六进制,与
v1做常量时间比较。 - 通过后按正文的
event_id去重;created_at早于 7 天零 5 分钟前或晚于现在 5 分钟以上的事件也要拒绝。
示例代码
import base64
import hashlib
import hmac
import time
def verify(signing_secret, header, body):
fields = {}
for item in header.split(","):
name, _, value = item.strip().partition("=")
fields[name] = value
t, v1 = fields.get("t", ""), fields.get("v1", "")
if not t.isdigit() or abs(time.time() - int(t)) > 300:
return False
key = base64.urlsafe_b64decode(signing_secret + "=" * (-len(signing_secret) % 4))
signed = b"chenvoya.v1." + t.encode() + b"." + body
expected = hmac.new(key, signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)// 需要 Node.js 20 及以上版本,保存为 .mjs 文件后运行。
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(signingSecret, header, body) {
const fields = {};
for (const item of header.split(",")) {
const at = item.indexOf("=");
if (at > 0) fields[item.slice(0, at).trim()] = item.slice(at + 1);
}
const t = fields.t ?? "", v1 = fields.v1 ?? "";
if (!/^[0-9]+$/.test(t) || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const key = Buffer.from(signingSecret, "base64url");
const expected = createHmac("sha256", key).update("chenvoya.v1." + t + ".").update(body).digest("hex");
return v1.length === expected.length && timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}package callback
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"strconv"
"strings"
"time"
)
// Verify reports whether body carries a valid Chenvoya-Signature header.
func Verify(signingSecret, header string, body []byte) bool {
fields := map[string]string{}
for _, item := range strings.Split(header, ",") {
name, value, _ := strings.Cut(strings.TrimSpace(item), "=")
fields[name] = value
}
t, err := strconv.ParseInt(fields["t"], 10, 64)
if err != nil || t < 0 || time.Since(time.Unix(t, 0)).Abs() > 5*time.Minute {
return false
}
key, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(signingSecret, "="))
if err != nil {
return false
}
mac := hmac.New(sha256.New, key)
mac.Write([]byte("chenvoya.v1." + fields["t"] + "."))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(fields["v1"]))
}检查清单
- 用收到的原始字节计算签名,不要先解析 JSON 再重新编码。
- 用常量时间比较,不要用普通的字符串相等。
- 服务器时钟要准确,否则 5 分钟的时间窗口会误拒。
- 按
event_id去重,处理过的事件 ID 至少保存 7 天零 5 分钟。 - 签名密钥和 API 密钥一样只放在服务器上。