主题
04 · 签名规范
平台使用 secp256k1(以太坊同款曲线)+ SHA-256 做双向签名:
- 商户 → 平台:商户用自己的私钥签名请求,放在
X-Signature头。平台用商户登记的公钥验签。 - 平台 → 商户:平台用平台私钥签名回调报文和部分响应体(
sig字段)。商户用平台公钥验签。
1. 请求签名(X-Signature)
1.1 算法
message = requestPath + sortedCompactJSON(requestBody)
messageHash = SHA256(message) // 32 字节
signature = secp256k1_sign(messageHash, 商户私钥) // 65 字节:r(32) || s(32) || v(1)
X-Signature = "0x" + hex(signature) // 132 字符(含 0x)1.2 requestPath
不含域名、不含 query string,就是以 /api/v1/client 开头的路径:
/api/v1/client/project/Ab3xY9Kq7Z/withdraw
/api/v1/client/project/Ab3xY9Kq7Z/user/generate-deposit-address
/api/v1/client/project/Ab3xY9Kq7Z/user/generate-deposit-address-by-user-uid平台内部会把网关路径
/api/v1/server/client/...还原成/api/v1/client/...再验签, 商户端永远用/api/v1/client/...参与签名即可。
1.3 sortedCompactJSON
对请求体做递归键名字典序排序,再序列化成紧凑 JSON(无空格、无换行)。
原始: {"network_id": 4, "count": 3}
排序: {"count":3,"network_id":4}排序规则:
- 对象的键按字典序(ASCII 升序)排序;
- 嵌套对象递归排序;
- 数组保持原有顺序,但数组内的对象元素递归排序;
- 非对象的标量值原样保留。
请求体为空时,
message就等于requestPath。
平台服务端会对收到的原始 body 执行同样的排序后再比对,所以你实际发送的 body 的键顺序不影响验签, 但内容必须与签名时使用的完全一致(不能签一份、发另一份)。
1.4 完整示例
请求:POST /api/v1/client/project/Ab3xY9Kq7Z/withdraw
body(发送内容):
json
{"amount":"100.5","asset_id":2,"network_id":56,"to_address":"0x4b2048ede5ca5d962795fec5edf8b41a860e2d4d","uid":"5f9a1c8e-3d2b-4a71-9d0e-6c1f2ab34567"}待签名字符串:
/api/v1/client/project/Ab3xY9Kq7Z/withdraw{"amount":"100.5","asset_id":2,"network_id":56,"to_address":"0x4b2048ede5ca5d962795fec5edf8b41a860e2d4d","uid":"5f9a1c8e-3d2b-4a71-9d0e-6c1f2ab34567"}请求头:
Content-Type: application/json
X-Signature: 0x<130 位十六进制>1.5 Go 实现
go
package sign
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"sort"
"strings"
"github.com/ethereum/go-ethereum/crypto"
)
// SortedCompactJSON 递归排序键名后输出紧凑 JSON
func SortedCompactJSON(v interface{}) (string, error) {
b, err := json.Marshal(v)
if err != nil {
return "", err
}
var m map[string]interface{}
if err := json.Unmarshal(b, &m); err != nil {
return "", err
}
out, err := json.Marshal(sortMap(m))
if err != nil {
return "", err
}
return string(out), nil
}
func sortMap(m map[string]interface{}) map[string]interface{} {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
sorted := make(map[string]interface{}, len(m))
for _, k := range keys {
switch val := m[k].(type) {
case map[string]interface{}:
sorted[k] = sortMap(val)
case []interface{}:
arr := make([]interface{}, len(val))
for i, e := range val {
if em, ok := e.(map[string]interface{}); ok {
arr[i] = sortMap(em)
} else {
arr[i] = e
}
}
sorted[k] = arr
default:
sorted[k] = val
}
}
return sorted
}
// SignRequest 生成 X-Signature 头的值
func SignRequest(path string, body interface{}, privateKeyHex string) (string, error) {
message := path
if body != nil {
sorted, err := SortedCompactJSON(body)
if err != nil {
return "", err
}
message = path + sorted
}
hash := sha256.Sum256([]byte(message))
priv, err := crypto.HexToECDSA(strings.TrimPrefix(privateKeyHex, "0x"))
if err != nil {
return "", err
}
sig, err := crypto.Sign(hash[:], priv) // 65 字节
if err != nil {
return "", err
}
return "0x" + hex.EncodeToString(sig), nil
}Go 商户直接用 SDK 即可,SDK 内部就是这段逻辑。见 05-Go-SDK.md。
1.6 Node.js 实现
js
const crypto = require('crypto');
const secp256k1 = require('secp256k1'); // npm i secp256k1
function sortValue(v) {
if (Array.isArray(v)) return v.map(sortValue);
if (v && typeof v === 'object') {
return Object.keys(v).sort().reduce((acc, k) => {
acc[k] = sortValue(v[k]);
return acc;
}, {});
}
return v;
}
function signRequest(path, body, privateKeyHex) {
const message = body ? path + JSON.stringify(sortValue(body)) : path;
const hash = crypto.createHash('sha256').update(message, 'utf8').digest();
const priv = Buffer.from(privateKeyHex.replace(/^0x/, ''), 'hex');
const { signature, recid } = secp256k1.ecdsaSign(hash, priv);
// 65 字节:r||s||v,v 为 recovery id(0/1)
const full = Buffer.concat([Buffer.from(signature), Buffer.from([recid])]);
return '0x' + full.toString('hex');
}
JSON.stringify对已排序对象的输出天然是紧凑格式,与 Go 端一致。 注意数字序列化:整数4两端都输出4;避免在 body 里放科学计数法或超出双精度范围的数字。
1.7 Python 实现
python
import hashlib, json
from coincurve import PrivateKey # pip install coincurve
def sort_value(v):
if isinstance(v, list):
return [sort_value(x) for x in v]
if isinstance(v, dict):
return {k: sort_value(v[k]) for k in sorted(v)}
return v
def sign_request(path: str, body: dict | None, private_key_hex: str) -> str:
if body is None:
message = path
else:
compact = json.dumps(sort_value(body), separators=(',', ':'), ensure_ascii=False)
message = path + compact
digest = hashlib.sha256(message.encode('utf-8')).digest()
pk = PrivateKey(bytes.fromhex(private_key_hex.removeprefix('0x')))
sig = pk.sign_recoverable(digest, hasher=None) # 65 字节,末位是 recovery id
return '0x' + sig.hex()2. 密钥格式
| 项 | 格式 | 示例长度 |
|---|---|---|
| 私钥 | 0x + 64 位十六进制(32 字节) | 66 字符 |
| 公钥(未压缩) | 0x04 + 128 位十六进制(65 字节) | 132 字符 |
| 公钥(去前缀) | 128 位十六进制(64 字节) | 128 字符 |
| 签名 | 0x + 130 位十六进制(65 字节:r+s+v) | 132 字符 |
平台登记公钥时两种公钥格式都接受;验签时会自动补 0x04 前缀。
3. 验证平台签名
用于验证回调报文的 sig,以及(可选)验证响应体的 sig。
3.1 回调验签
message = sortedCompactJSON(verify_data) // 注意:不是整个报文
hash = SHA256(message)
verify(hash, sig[0:64], 平台公钥) // 丢弃第 65 字节的 recovery idGo(SDK):
go
ok, err := client.VerifyPlatformSignature(client.GetPlatformPublicKey(), verifyData, sig)Node.js:
js
function verifyPlatformSignature(verifyData, sigHex, platformPubKeyHex) {
const message = JSON.stringify(sortValue(verifyData));
const hash = crypto.createHash('sha256').update(message, 'utf8').digest();
const sig = Buffer.from(sigHex.replace(/^0x/, ''), 'hex').subarray(0, 64); // 去掉 v
let pub = Buffer.from(platformPubKeyHex.replace(/^0x/, ''), 'hex');
if (pub.length === 64) pub = Buffer.concat([Buffer.from([0x04]), pub]); // 补未压缩前缀
return secp256k1.ecdsaVerify(sig, hash, pub);
}Python:
python
import hashlib, json
from ecdsa import VerifyingKey, SECP256k1, BadSignatureError # pip install ecdsa
from ecdsa.util import sigdecode_string
def verify_platform_signature(verify_data: dict, sig_hex: str, platform_pub_hex: str) -> bool:
message = json.dumps(sort_value(verify_data), separators=(',', ':'), ensure_ascii=False)
digest = hashlib.sha256(message.encode('utf-8')).digest()
raw = bytes.fromhex(sig_hex.removeprefix('0x'))[:64] # 去掉末位 recovery id,只留 r||s
pub_hex = platform_pub_hex.removeprefix('0x')
if len(pub_hex) == 130 and pub_hex.startswith('04'): # 去掉未压缩前缀
pub_hex = pub_hex[2:]
vk = VerifyingKey.from_string(bytes.fromhex(pub_hex), curve=SECP256k1)
try:
return vk.verify_digest(raw, digest, sigdecode=sigdecode_string)
except BadSignatureError:
return False3.2 响应验签(可选)
部分接口的响应带 sig,签名对象是把 sig 置为空字符串后的整个响应体:
message = sortedCompactJSON({"code":200,"status":"success","message":"","data":{...},"error":"","sig":""})即:先把响应 JSON 里的 sig 改成 "",再做递归排序 + 紧凑序列化 + SHA256 + 验签。
响应体的
sig不带0x前缀;回调的sig同样不带前缀。请求签名的X-Signature带0x前缀。 各端实现建议统一strip("0x")后再处理。
4. 常见验签失败原因
| 原因 | 表现 |
|---|---|
| 用了完整 URL(含域名或 query)参与签名 | 401 invalid signature |
用了 /api/v1/server/client/... 而不是 /api/v1/client/... | 401 invalid signature |
| 没有做键名排序,或只排了一层没递归 | 401 invalid signature |
| 序列化带了空格/缩进 | 401 invalid signature |
| 签名的 body 与实际发送的 body 不一致(比如中间件改写了 body) | 401 invalid signature |
| 商户公钥未登记或登记错了 | 400 Project public key not found |
用 eth_sign/EIP-191 前缀签名(多了 \x19Ethereum Signed Message:\n32) | 401 invalid signature —— 本协议不加任何前缀,直接对 SHA-256 摘要签名 |
| 签名只有 64 字节(缺 recovery id) | 平台取 sig[:len-1] 会截断 s,导致验签失败。必须是 65 字节 |