签名契约
中台与站点之间只有一条信任边界:HMAC-SHA256 签名。所有 /api/v1/* 接口都必须带四个请求头。
算法(可跨语言实现)
签名串 = METHOD "\n" PATH "\n" TIMESTAMP "\n" NONCE "\n" SHA256_HEX(原始请求体)
签名 = hex( HMAC_SHA256(密钥, 签名串) )
| 请求头 | 含义 | 约束 |
|---|---|---|
X-Hub-Site | 站点短码 | 注册时定的那个 |
X-Hub-Time | Unix 秒 | 与中台时钟偏差 ≤ 300 秒 |
X-Hub-Nonce | 随机串 | 16–64 位;同一站点 15 分钟内不可重复 |
X-Hub-Signature | 签名 | 64 位小写十六进制 |
四条约定,违反任一条都会 401
- 签名接口一律 POST,
PATH只取 URL 的路径部分,不含域名、不含 query、不含末尾多余斜杠。不做 query 归一化是因为各家框架对参数的排序与编码不一致,跨语言一定会踩。需要传参就放进 JSON body。 - 签名基于原始字节的请求体。先
json_decode再json_encode一遍,字节序就变了,签名必然对不上。 - 密钥不进代码仓库。放环境变量或站点自己的加密配置里;两把密钥(接入 / 回调)分开使用、分开保管。
- 出站回调(中台 → 站点)用同一套算法,但密钥换成「回调密钥」,请求头一样是这四个。
PHP 实现(15 行,不依赖任何库)
function hub_sign(string $secret, string $method, string $path, int $ts, string $nonce, string $body): string {
$s = strtoupper($method) . "\n" . '/' . ltrim($path, '/') . "\n" . $ts . "\n" . $nonce . "\n" . hash('sha256', $body);
return hash_hmac('sha256', $s, $secret);
}
$body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$ts = time();
$nonce = bin2hex(random_bytes(12));
$sig = hub_sign($secret, 'POST', '/api/v1/pay/order', $ts, $nonce, $body);
$ch = curl_init('https://wx.onzx.cn/api/v1/pay/order');
curl_setopt_array($ch, [
CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 8, CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-Hub-Site: ' . $siteKey, 'X-Hub-Time: ' . $ts, 'X-Hub-Nonce: ' . $nonce, 'X-Hub-Signature: ' . $sig,
],
]);
$r = json_decode((string)curl_exec($ch), true);
if (empty($r['ok'])) { /* 按 $r['code'] 分支:见错误码总表 */ }
响应信封
// 成功
{ "ok": true, "data": { … }, "hub": "0.1.0" }
// 失败(HTTP 4xx/5xx 同时给出)
{ "ok": false, "code": "sign_mismatch", "msg": "签名与密钥不匹配…", "hint": "…" }
按 code 分支处理,不要按 msg 文案判断 —— 文案会为可读性调整,code 不会变。
401 的排查顺序(按命中概率排)
| code | 十有八九是这个原因 |
|---|---|
sign_mismatch | body 被重新编码过;或 path 带了域名/query;或用了另一个站点的密钥 |
sign_window | 服务器时钟不同步(先 ntpdate/chrony 对时,别改容差) |
nonce_replay | nonce 复用 —— 每次请求都要新的,别把整个请求对象缓存了 |
sign_format | 签名是大写、或带了 sha256= 前缀、或被 base64 了 |
secret_missing | 站点刚创建或刚重置过密钥,还没配到代码里 |
把「双方算出的签名串」逐字符比对是最快的定位方式:用诊断台的签名计算器 / 反向验签。
shell 版(手测用)
export WXHUB_SECRET=你的接入密钥
BODY='{}'
TIME=$(date +%s)
NONCE=$(openssl rand -hex 12)
HASH=$(printf '%s' "$BODY" | openssl dgst -sha256 -r | cut -d' ' -f1)
SIGN=$(printf '%s\n%s\n%s\n%s\n%s' 'POST' '/api/v1/ping' "$TIME" "$NONCE" "$HASH" \
| openssl dgst -sha256 -hmac "$WXHUB_SECRET" -r | cut -d' ' -f1)
curl -sS -X POST 'https://wx.onzx.cn/api/v1/ping' \
-H 'Content-Type: application/json' -H 'X-Hub-Site: 你的短码' \
-H "X-Hub-Time: $TIME" -H "X-Hub-Nonce: $NONCE" -H "X-Hub-Signature: $SIGN" \
--data "$BODY"
本页内容随代码一起版本化(V0.1.0)。发现文档与实际行为不一致,请当缺陷报告。