签名校验
平台发往你钱包的每个请求都按 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 bodysignature = "v1," + base64(HMAC-SHA256(key, signed))GET 请求对空请求体签名。
- 读取收到的原始请求体字节。不要先解析 JSON 再重新序列化:哪怕多一个空格,签名就对不上。
- 读取三个头部(头部名不区分大小写)。缺失或时间戳不是数字 → 拒绝。
- 若
|当前时间 − webhook-timestamp| > 300秒 → 拒绝。服务器请开启 NTP 校时。 - 对每一把有效密钥计算期望的
v1签名,用常数时间比较与webhook-signature中每一个v1,条目比对;忽略其他版本前缀的条目。 - 任一条目匹配即通过,否则返回 401。
- 可选的防重放:把通过校验的
webhook-id记住 10 分钟,拒绝重复。平台每次重试都使用新 id,所以这不会挡住正常重试。
在「商户控制台 → 接入」(先选「沙盒」或「生产」标签页)的「签名密钥」卡片中点击「轮换密钥」。之后 24 小时内,平台对每个请求同时用新密钥和旧密钥签名(两个 v1, 条目)。把新密钥与旧密钥一起部署到你的校验逻辑(下面所有示例都支持密钥列表),确认流量校验通过后,再删除旧密钥。密钥只显示一次,我们只保存指纹。
下面每个校验函数都不依赖第三方库,并已用 Standard Webhooks 官方测试向量和 Star Game 平台签名器生成的向量测试(见测试向量)。错误分四类:bad_secret、missing_headers、stale_timestamp、no_matching_signature。
校验函数
// 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");}接入示例
// 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 rotatingconst 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();});校验函数
// Package stargame verifies Star Game platform requests (Standard Webhooks, HMAC-SHA256). Standard library only.package stargame
import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "errors" "net/http" "strconv" "strings" "time")
const Tolerance = 300 * time.Second
var ( ErrBadSecret = errors.New("bad_secret") ErrMissingHeaders = errors.New("missing_headers") ErrStaleTimestamp = errors.New("stale_timestamp") ErrNoMatchingSignature = errors.New("no_matching_signature"))
// DecodeSecret decodes a whsec_<base64> secret into the raw HMAC key.func DecodeSecret(secret string) ([]byte, error) { raw, ok := strings.CutPrefix(secret, "whsec_") if !ok { return nil, ErrBadSecret } key, err := base64.StdEncoding.DecodeString(raw) if err != nil || len(key) == 0 { return nil, ErrBadSecret } return key, nil}
// Verify checks the webhook-* headers against the exact request body bytes and returns the// webhook-id for replay protection. secrets lists every secret currently accepted (two while rotating).func Verify(secrets []string, h http.Header, body []byte, now time.Time) (string, error) { keys := make([][]byte, 0, len(secrets)) for _, s := range secrets { k, err := DecodeSecret(s) if err != nil { return "", err } keys = append(keys, k) } id, tsRaw, sigRaw := h.Get("webhook-id"), h.Get("webhook-timestamp"), h.Get("webhook-signature") if id == "" || sigRaw == "" || tsRaw == "" || strings.Trim(tsRaw, "0123456789") != "" { return "", ErrMissingHeaders } ts, err := strconv.ParseInt(tsRaw, 10, 64) // err only on overflow: far outside the window if d := now.Sub(time.Unix(ts, 0)); err != nil || d > Tolerance || d < -Tolerance { return "", ErrStaleTimestamp } for _, key := range keys { mac := hmac.New(sha256.New, key) mac.Write([]byte(id + "." + tsRaw + ".")) mac.Write(body) expected := mac.Sum(nil) for _, sig := range strings.Fields(sigRaw) { b64, ok := strings.CutPrefix(sig, "v1,") if !ok { continue } got, err := base64.StdEncoding.DecodeString(b64) if err == nil && hmac.Equal(got, expected) { return id, nil } } } return "", ErrNoMatchingSignature}接入示例
// Package handler shows net/http middleware that verifies every wallet request.package handler
import ( "bytes" "io" "net/http" "time"
"example.com/stargame-signature/stargame")
// WalletAuth rejects unsigned or badly signed requests with a 401 problem+json and// restores the body for the next handler. secrets holds every accepted whsec_ secret.func WalletAuth(secrets []string, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 4<<20)) if err == nil { _, err = stargame.Verify(secrets, r.Header, body, time.Now()) } if err != nil { w.Header().Set("Content-Type", "application/problem+json") w.WriteHeader(http.StatusUnauthorized) _, _ = io.WriteString(w, `{"type":"about:blank","title":"Invalid signature","status":401,"code":"invalid_signature"}`) return } r.Body = io.NopCloser(bytes.NewReader(body)) next.ServeHTTP(w, r) })}校验函数
"""Verifies Star Game platform requests (Standard Webhooks, HMAC-SHA256). Python 3.8+, standard library only."""import base64import binasciiimport hashlibimport hmacimport reimport timefrom typing import Mapping, Optional, Sequence
TOLERANCE_SECONDS = 300
class SignatureError(Exception): """code is one of bad_secret, missing_headers, stale_timestamp, no_matching_signature."""
def __init__(self, code: str) -> None: super().__init__(code) self.code = code
def decode_secret(secret: str) -> bytes: """Decodes a whsec_<base64> secret into the raw HMAC key.""" if not secret.startswith("whsec_"): raise SignatureError("bad_secret") try: key = base64.b64decode(secret[len("whsec_"):], validate=True) except (binascii.Error, ValueError): raise SignatureError("bad_secret") from None if not key: raise SignatureError("bad_secret") return key
def verify_webhook(secrets: Sequence[str], headers: Mapping[str, str], raw_body: bytes, now: Optional[int] = None) -> str: """Checks the webhook-* headers against the exact request body bytes; returns the webhook-id for replay protection.
secrets lists every secret currently accepted (two while rotating). headers must be looked up case-insensitively by your framework (e.g. request.headers in Flask/Django/FastAPI).""" keys = [decode_secret(s) for s in secrets] msg_id = headers.get("webhook-id", "") timestamp = headers.get("webhook-timestamp", "") signature = headers.get("webhook-signature", "") if not msg_id or not signature or not re.fullmatch(r"[0-9]+", timestamp): raise SignatureError("missing_headers") now = int(time.time()) if now is None else now if abs(now - int(timestamp)) > TOLERANCE_SECONDS: raise SignatureError("stale_timestamp")
signed = f"{msg_id}.{timestamp}.".encode() + raw_body received = [s[3:].encode() for s in signature.split(" ") if s.startswith("v1,")] for key in keys: expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()) if any(hmac.compare_digest(sig, expected) for sig in received): return msg_id raise SignatureError("no_matching_signature")接入示例
"""Flask: verify every wallet request before your views run."""import os
from flask import Flask, request
from stargame_verify import SignatureError, verify_webhook
SECRETS = os.environ["STARGAME_WALLET_SECRETS"].split(",") # "whsec_new,whsec_old" while rotatingapp = Flask(__name__)
@app.before_requestdef check_signature(): try: verify_webhook(SECRETS, request.headers, request.get_data()) except SignatureError: problem = {"type": "about:blank", "title": "Invalid signature", "status": 401, "code": "invalid_signature"} return problem, 401, {"Content-Type": "application/problem+json"} return None校验函数
<?php// Verifies Star Game platform requests (Standard Webhooks, HMAC-SHA256). PHP 7.4+, no extensions beyond core.
declare(strict_types=1);
const STARGAME_TOLERANCE_SECONDS = 300;
final class StarGameSignatureError extends RuntimeException{ /** One of bad_secret, missing_headers, stale_timestamp, no_matching_signature. */ public string $errorCode;
public function __construct(string $code) { parent::__construct($code); $this->errorCode = $code; }}
/** Decodes a whsec_<base64> secret into the raw HMAC key. */function stargame_decode_secret(string $secret): string{ if (strncmp($secret, 'whsec_', 6) !== 0) { throw new StarGameSignatureError('bad_secret'); } $key = base64_decode(substr($secret, 6), true); if ($key === false || $key === '') { throw new StarGameSignatureError('bad_secret'); } return $key;}
/** * Checks the webhook-* headers against the exact request body (file_get_contents('php://input')) * and returns the webhook-id for replay protection. * * @param string[] $secrets every secret currently accepted (two while rotating) */function stargame_verify_webhook(array $secrets, string $id, string $timestamp, string $signature, string $rawBody, ?int $now = null): string{ $keys = array_map('stargame_decode_secret', $secrets); if ($id === '' || $signature === '' || !preg_match('/^[0-9]+$/', $timestamp)) { throw new StarGameSignatureError('missing_headers'); } $now = $now ?? time(); if (abs($now - (int) $timestamp) > STARGAME_TOLERANCE_SECONDS) { throw new StarGameSignatureError('stale_timestamp'); }
$signed = $id . '.' . $timestamp . '.' . $rawBody; foreach ($keys as $key) { $expected = base64_encode(hash_hmac('sha256', $signed, $key, true)); foreach (explode(' ', $signature) as $candidate) { if (strncmp($candidate, 'v1,', 3) === 0 && hash_equals($expected, substr($candidate, 3))) { return $id; } } } throw new StarGameSignatureError('no_matching_signature');}接入示例
<?php// Plain PHP front controller: verify the request, then decode the raw body you verified.
declare(strict_types=1);
require __DIR__ . '/verify.php';
$secrets = explode(',', (string) getenv('STARGAME_WALLET_SECRETS')); // "whsec_new,whsec_old" while rotating$rawBody = (string) file_get_contents('php://input');
try { stargame_verify_webhook( $secrets, $_SERVER['HTTP_WEBHOOK_ID'] ?? '', $_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? '', $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? '', $rawBody );} catch (StarGameSignatureError $e) { http_response_code(401); header('Content-Type: application/problem+json'); echo json_encode(['type' => 'about:blank', 'title' => 'Invalid signature', 'status' => 401, 'code' => 'invalid_signature']); exit;}
$request = $rawBody === '' ? null : json_decode($rawBody, true);校验函数
// Verifies Star Game platform requests (Standard Webhooks, HMAC-SHA256). Java 11+, JDK only.
import java.nio.charset.StandardCharsets;import java.security.GeneralSecurityException;import java.security.MessageDigest;import java.util.ArrayList;import java.util.Base64;import java.util.List;import javax.crypto.Mac;import javax.crypto.spec.SecretKeySpec;
public final class StarGameSignature { public static final long TOLERANCE_SECONDS = 300;
/** getMessage() is one of bad_secret, missing_headers, stale_timestamp, no_matching_signature. */ public static final class SignatureException extends Exception { public SignatureException(String code) { super(code); } }
private StarGameSignature() {}
/** Decodes a whsec_<base64> secret into the raw HMAC key. */ public static byte[] decodeSecret(String secret) throws SignatureException { if (!secret.startsWith("whsec_")) throw new SignatureException("bad_secret"); try { byte[] key = Base64.getDecoder().decode(secret.substring(6)); if (key.length == 0) throw new SignatureException("bad_secret"); return key; } catch (IllegalArgumentException e) { throw new SignatureException("bad_secret"); } }
/** * Checks the webhook-* headers against the exact request body bytes and returns the webhook-id * for replay protection. {@code secrets} lists every secret currently accepted (two while rotating). */ public static String verify(List<String> secrets, String id, String timestamp, String signature, byte[] rawBody, long nowSeconds) throws SignatureException { List<byte[]> keys = new ArrayList<>(); for (String s : secrets) keys.add(decodeSecret(s)); if (id == null || id.isEmpty() || signature == null || signature.isEmpty() || timestamp == null || !timestamp.matches("[0-9]+")) { throw new SignatureException("missing_headers"); } try { if (Math.abs(nowSeconds - Long.parseLong(timestamp)) > TOLERANCE_SECONDS) { throw new SignatureException("stale_timestamp"); } } catch (NumberFormatException overflow) { // digits only, so this is a value far outside the window throw new SignatureException("stale_timestamp"); }
byte[] prefix = (id + "." + timestamp + ".").getBytes(StandardCharsets.UTF_8); for (byte[] key : keys) { byte[] expected; try { Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(key, "HmacSHA256")); mac.update(prefix); expected = mac.doFinal(rawBody); } catch (GeneralSecurityException e) { throw new IllegalStateException(e); } for (String candidate : signature.split(" ")) { if (!candidate.startsWith("v1,")) continue; byte[] got; try { got = Base64.getDecoder().decode(candidate.substring(3)); } catch (IllegalArgumentException e) { continue; } if (MessageDigest.isEqual(got, expected)) return id; } } throw new SignatureException("no_matching_signature"); }
public static String verify(List<String> secrets, String id, String timestamp, String signature, byte[] rawBody) throws SignatureException { return verify(secrets, id, timestamp, signature, rawBody, System.currentTimeMillis() / 1000); }}接入示例
// Spring MVC: take the body as raw bytes, verify, then parse.@PostMapping(path = "/game-wallet/v1/transactions", consumes = MediaType.APPLICATION_JSON_VALUE)public ResponseEntity<?> transactions(@RequestHeader HttpHeaders headers, @RequestBody byte[] rawBody) throws IOException { try { StarGameSignature.verify(secrets, // List.of("whsec_new", "whsec_old") while rotating headers.getFirst("webhook-id"), headers.getFirst("webhook-timestamp"), headers.getFirst("webhook-signature"), rawBody); } catch (StarGameSignature.SignatureException e) { return ResponseEntity.status(401).contentType(MediaType.APPLICATION_PROBLEM_JSON) .body(Map.of("type", "about:blank", "title", "Invalid signature", "status", 401, "code", "invalid_signature")); } TransactionBatch batch = objectMapper.readValue(rawBody, TransactionBatch.class); return ResponseEntity.ok(wallet.apply(batch));}你也可以使用 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 秒。