跳转到内容

签名校验

平台发往你钱包的每个请求都按 Standard Webhooks 规范签名。请在解析或处理请求体之前先校验签名,校验失败时返回 401 和 code: "invalid_signature"。

头部 值
webhook-id msg_<随机>,每次 HTTP 尝试唯一
webhook-timestamp 签名时的 Unix 时间(秒)
webhook-signature 一个或多个以空格分隔的 v1,<base64 签名>
key = base64_decode(secret without the "whsec_" prefix)
signed = webhook-id + "." + webhook-timestamp + "." + raw request body
signature = "v1," + base64(HMAC-SHA256(key, signed))

GET 请求对空请求体签名。

  1. 读取收到的原始请求体字节。不要先解析 JSON 再重新序列化:哪怕多一个空格,签名就对不上。
  2. 读取三个头部(头部名不区分大小写)。缺失或时间戳不是数字 → 拒绝。
  3. 若 |当前时间 − webhook-timestamp| > 300 秒 → 拒绝。服务器请开启 NTP 校时。
  4. 对每一把有效密钥计算期望的 v1 签名,用常数时间比较与 webhook-signature 中每一个 v1, 条目比对;忽略其他版本前缀的条目。
  5. 任一条目匹配即通过,否则返回 401。
  6. 可选的防重放:把通过校验的 webhook-id 记住 10 分钟,拒绝重复。平台每次重试都使用新 id,所以这不会挡住正常重试。

在「商户控制台 → 接入」(先选「沙盒」或「生产」标签页)的「签名密钥」卡片中点击「轮换密钥」。之后 24 小时内,平台对每个请求同时用新密钥和旧密钥签名(两个 v1, 条目)。把新密钥与旧密钥一起部署到你的校验逻辑(下面所有示例都支持密钥列表),确认流量校验通过后,再删除旧密钥。密钥只显示一次,我们只保存指纹。

下面每个校验函数都不依赖第三方库,并已用 Standard Webhooks 官方测试向量和 Star Game 平台签名器生成的向量测试(见测试向量)。错误分四类:bad_secret、missing_headers、stale_timestamp、no_matching_signature。

校验函数

verify.mjs
// Verifies Star Game platform requests (Standard Webhooks, HMAC-SHA256). Node.js 18+, no dependencies.
import { createHmac, timingSafeEqual } from "node:crypto";
export const TOLERANCE_SECONDS = 300;
export class SignatureError extends Error {
/** @param {"bad_secret" | "missing_headers" | "stale_timestamp" | "no_matching_signature"} code */
constructor(code) {
super(code);
this.code = code;
}
}
/** Decodes a `whsec_<base64>` secret into the raw HMAC key. */
export function decodeSecret(secret) {
const raw = secret.startsWith("whsec_") ? secret.slice(6) : null;
if (!raw || !/^[A-Za-z0-9+/]+={0,2}$/.test(raw)) throw new SignatureError("bad_secret");
return Buffer.from(raw, "base64");
}
/**
* Verifies the webhook-* headers against the exact request body bytes.
* @param {string[]} secrets every secret currently accepted (two while rotating)
* @param {{ id?: string, timestamp?: string, signature?: string }} headers
* @param {Buffer | string} rawBody the body exactly as received; never a re-serialised JSON object
* @param {number} [now] current unix time in seconds
* @returns {string} the webhook-id, for replay protection
*/
export function verifyWebhook(secrets, headers, rawBody, now = Math.floor(Date.now() / 1000)) {
const keys = secrets.map(decodeSecret);
const { id, timestamp, signature } = headers;
if (!id || !timestamp || !signature || !/^\d+$/.test(timestamp)) throw new SignatureError("missing_headers");
if (Math.abs(now - Number(timestamp)) > TOLERANCE_SECONDS) throw new SignatureError("stale_timestamp");
const signed = Buffer.concat([Buffer.from(`${id}.${timestamp}.`), Buffer.from(rawBody)]);
const received = signature
.split(" ")
.filter((s) => s.startsWith("v1,"))
.map((s) => Buffer.from(s.slice(3), "base64"));
for (const key of keys) {
const expected = createHmac("sha256", key).update(signed).digest();
if (received.some((sig) => sig.length === expected.length && timingSafeEqual(sig, expected))) return id;
}
throw new SignatureError("no_matching_signature");
}

接入示例

usage-express.mjs
// Express: verify every wallet request before your handlers see it.
import express from "express";
import { SignatureError, verifyWebhook } from "./verify.mjs";
const secrets = process.env.STARGAME_WALLET_SECRETS.split(","); // "whsec_new,whsec_old" while rotating
const app = express();
app.use("/game-wallet/v1", express.raw({ type: "*/*", limit: "4mb" }), (req, res, next) => {
const rawBody = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0); // GET requests sign an empty body
try {
verifyWebhook(secrets, { id: req.get("webhook-id"), timestamp: req.get("webhook-timestamp"), signature: req.get("webhook-signature") }, rawBody);
} catch (e) {
if (!(e instanceof SignatureError)) return next(e);
return res
.status(401)
.type("application/problem+json")
.send(JSON.stringify({ type: "about:blank", title: "Invalid signature", status: 401, code: "invalid_signature" }));
}
req.wallet = rawBody.length ? JSON.parse(rawBody.toString("utf8")) : null;
next();
});

你也可以使用 Standard Webhooks 官方库,它们接受同样的 whsec_… 密钥。

语言 包
JavaScript / TypeScript standardwebhooks(npm)
Python standardwebhooks(PyPI)
Go github.com/standard-webhooks/standard-webhooks/libraries/go
Java / Kotlin com.standardwebhooks:standardwebhooks(Maven Central)
PHP standard-webhooks 仓库中的 libraries/php

使用官方库时如需多把密钥(轮换),逐把尝试即可。

可用于你自己实现的单元测试。

Standard Webhooks 官方向量

字段 值
secret whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw
webhook-id msg_p5jXN8AQM9LWM0D4loKWxJek
webhook-timestamp 1614265330
body {"test": 2432232314}
webhook-signature v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=

Star Game 平台向量(测试密钥,非真实密钥)

字段 值
secret whsec_c3RhcmdhbWUtZG9jcy10ZXN0LXZlY3Rvci1rZXktMzI=
webhook-id msg_stargame_docs_vector_0001
webhook-timestamp 1791763200
body {"transactions":[{"transaction_id":"lantern:59666:p1:bet","type":"debit","player_id":"p1","session_id":"s1","amount":"1500","currency":"GOLD","game_id":"lantern","round_id":"lantern:59666"}]}
webhook-signature v1,v3cMUzEE3WjapPtMQQFOz2A/RM8BzVBh5ccL6P0/L+I=

还应确认会拒绝:请求体被改、id 被改、时间戳早于或晚于 301 秒、签名头只有 v2,… 条目;并且在多个条目中只要有一个 v1 匹配就通过。

  • 校验的是重新序列化后的 JSON,而不是原始请求体。
  • 用 == 比较签名,而不是常数时间比较。
  • 把带 whsec_ 前缀的整串当作 HMAC 密钥——密钥是去掉前缀后 base64 解码的结果。
  • 只比对第一个 v1, 条目——轮换期间匹配的可能是第二个。
  • 服务器时钟偏差超过 300 秒。