# 验证回调签名 用登记地址时拿到的签名密钥,验证每个通知确实来自我们。 每个通知都带 `Chenvoya-Signature` 请求头。先验证签名,再读取正文;验证失败时返回非 2xx 状态并丢弃这次请求。 ## 签名格式 ```text Chenvoya-Signature: t=1793167400,v1=<64 位小写十六进制> ``` `t` 是签名时间(Unix 秒),`v1` 是小写十六进制的 HMAC-SHA256。 ## 验证步骤 1. 把登记时取得的 `signing_secret` 做 base64url 解码,得到 32 字节的验签密钥。 2. 从 `Chenvoya-Signature` 拆出 `t` 和 `v1`。 3. `t` 与本地时间相差超过 5 分钟时拒绝。 4. 用验签密钥计算 HMAC-SHA256,输入是 `chenvoya.v1.`、`t`、一个点 `.`,再接正文的原始字节。 5. 把结果写成小写十六进制,与 `v1` 做常量时间比较。 6. 通过后按正文的 `event_id` 去重;`created_at` 早于 7 天零 5 分钟前或晚于现在 5 分钟以上的事件也要拒绝。 ## 示例代码 ```python 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) ``` ```javascript // 需要 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)); } ``` ```go 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 密钥一样只放在服务器上。 ## 下一步 - [接收回调](./api-docs-zh-callbacks.md):登记回调地址后,任务结束和费用结算时我们向它发送签名的通知。 - [接收任务完成通知](./api-docs-zh-api-events-task-completed.md):任务进入成功、失败或取消终态后发送 task.completed;成功任务须已完成交付处理。