回调验签
业务后端使用 webhookSecret 校验原始请求字节。以下示例使用各语言标准库,不依赖 Server SDK。地址配置与投递确认见服务端回调。
算法
signed_payload = UTF8(x-eduskit-timestamp + ".") + raw_request_body_bytes
expected = HMAC_SHA256(UTF8(webhookSecret), signed_payload)
x-eduskit-signature = "v1=" + hex(expected)
示例接受与接收服务器当前时间相差不超过 300 秒的签名时间戳,并使用常量时间比较摘要。这是接收方的防重放策略,服务端没有替业务接口设置这个校验窗口。接收服务器应同步时钟;较早产生的事件可以带有新的投递时间戳。
在 JSON 中间件改写请求体之前读取字节,保留空格、换行、字段顺序和 UTF-8 内容。签名密钥按 UTF-8 文本使用,即使外观为十六进制字符串,也不先做 hex 解码。
选择语言
向函数传入原始正文、x-eduskit-timestamp、x-eduskit-signature 和配置的密钥。缺失请求头时拒绝请求或传空字符串;重复的签名头、时间戳头应拒绝。可显式传入时间的参数仅用于确定性测试,生产使用接收服务器当前时间。
Node.js 接收 Buffer;Python 接收 bytes;Java、Go 接收字节数组;PHP 使用 php://input 读取的原始字符串。Java 示例要求 Java 11+,PHP 示例要求 PHP 8.1+。Java 的加密计算异常应返回错误响应,不能确认接收成功。
- Node.js
- Python
- Java
- Go
- PHP
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyWebhook(
rawBody,
timestamp,
signature,
secret,
nowSeconds = Math.floor(Date.now() / 1000),
) {
if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
if (
typeof timestamp !== 'string' ||
timestamp.trim() !== timestamp ||
!/^[0-9]{1,16}$/.test(timestamp)
)
return false;
if (
typeof signature !== 'string' ||
signature.length !== 67 ||
!/^v1=[0-9a-fA-F]{64}$/.test(signature)
)
return false;
const sentAt = Number(timestamp);
if (!Number.isSafeInteger(sentAt) || Math.abs(nowSeconds - sentAt) > 300) return false;
const expected = createHmac('sha256', secret)
.update(timestamp + '.', 'utf8')
.update(rawBody)
.digest();
return timingSafeEqual(expected, Buffer.from(signature.slice(3), 'hex'));
}
import hashlib
import hmac
import re
import time
def verify_webhook(raw_body, timestamp, signature, secret, now_seconds=None):
if not isinstance(raw_body, bytes) or not isinstance(secret, str) or not secret:
return False
if not isinstance(timestamp, str) or not re.fullmatch(r'[0-9]{1,16}', timestamp):
return False
if not isinstance(signature, str) or not re.fullmatch(r'v1=[0-9a-fA-F]{64}', signature):
return False
now = int(time.time()) if now_seconds is None else now_seconds
if abs(now - int(timestamp)) > 300:
return False
expected = hmac.new(
secret.encode('utf-8'), timestamp.encode('ascii') + b'.' + raw_body, hashlib.sha256
).digest()
return hmac.compare_digest(expected, bytes.fromhex(signature[3:]))
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.time.Instant;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public final class WebhookSignature {
public static boolean verify(byte[] rawBody, String timestamp, String signature, String secret)
throws GeneralSecurityException {
return verify(rawBody, timestamp, signature, secret, Instant.now().getEpochSecond());
}
public static boolean verify(byte[] rawBody, String timestamp, String signature,
String secret, long nowSeconds) throws GeneralSecurityException {
if (rawBody == null || secret == null || secret.isEmpty()) return false;
if (timestamp == null || !timestamp.matches("[0-9]{1,16}")) return false;
if (signature == null || !signature.matches("v1=[0-9a-fA-F]{64}")) return false;
long sentAt = Long.parseLong(timestamp);
if (sentAt < nowSeconds - 300 || sentAt > nowSeconds + 300) return false;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
mac.update((timestamp + ".").getBytes(StandardCharsets.US_ASCII));
byte[] expected = mac.doFinal(rawBody);
byte[] supplied = new byte[32];
for (int i = 0; i < supplied.length; i++) {
supplied[i] = (byte) Integer.parseInt(signature.substring(3 + i * 2, 5 + i * 2), 16);
}
return MessageDigest.isEqual(expected, supplied);
}
}
package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"regexp"
"strconv"
"time"
)
var timestampPattern = regexp.MustCompile(`^[0-9]{1,16}$`)
var signaturePattern = regexp.MustCompile(`^v1=[0-9a-fA-F]{64}$`)
func Verify(rawBody []byte, timestamp, signature, secret string) bool {
return VerifyAt(rawBody, timestamp, signature, secret, time.Now().Unix())
}
func VerifyAt(rawBody []byte, timestamp, signature, secret string, nowSeconds int64) bool {
if secret == "" || !timestampPattern.MatchString(timestamp) || !signaturePattern.MatchString(signature) {
return false
}
sentAt, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil || sentAt < nowSeconds-300 || sentAt > nowSeconds+300 {
return false
}
supplied, err := hex.DecodeString(signature[3:])
if err != nil {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestamp + "."))
mac.Write(rawBody)
return hmac.Equal(mac.Sum(nil), supplied)
}
<?php
declare(strict_types=1);
function verifyWebhook(
string $rawBody,
string $timestamp,
string $signature,
string $secret,
?int $nowSeconds = null
): bool {
if ($secret === '' || preg_match('/\A[0-9]{1,16}\z/', $timestamp) !== 1) {
return false;
}
if (preg_match('/\Av1=[0-9a-fA-F]{64}\z/', $signature) !== 1) {
return false;
}
$now = $nowSeconds ?? time();
if (abs($now - (int) $timestamp) > 300) {
return false;
}
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret, true);
return hash_equals($expected, hex2bin(substr($signature, 3)));
}
验签之后
返回 true 仅表示签名和时间窗通过。继续解析正文、检查应用归属和请求头/正文事件标识,并完成幂等持久化后再确认接收。验签失败可返回 HTTP 401;平台会将其视为投递失败并重试,密钥不匹配时需修正配置。
验签函数文件位于文档仓库的 examples/webhooks/。将其接入业务 HTTP 路由,并配置事件持久化与去重。