HMAC 鉴权
Coinepay OpenAPI 使用 HMAC-SHA256 对每次请求签名。客户必须在每次请求头中提供 4 个值;服务器据此校验身份 + 时间窗 + 防重放 + 防篡改。
必需的 4 个请求头
| Header | 说明 | 示例 |
|---|---|---|
X-App-Id | 凭证标识,固定 31 字符(cp_ + 28 hex) | cp_a1b2c3d4e5f6071829304a5b6c7d8e9f |
X-Timestamp | Unix 秒(不是毫秒),ASCII 十进制 | 1714377600 |
X-Nonce | 8~64 位字符,10 分钟内对同一 AppID 唯一 | 8f7e6d5c4b3a29180a1b2c3d4e5f6071 |
X-Signature | HMAC-SHA256 lowercase hex,64 字符 | 9b8e7f6d5c4b3a... |
时间单位
X-Timestamp 必须是秒,不是毫秒。Math.floor(Date.now() / 1000) 不是 Date.now()。
签名输入构造
text
signInput = METHOD + LF + PATH + LF + RAW_QUERY + LF + TIMESTAMP + LF + NONCE + LF + BODY_SHA256_HEX| 符号 | 含义 |
|---|---|
LF | 字符 \n(单字节 0x0A),不是 \r\n |
+ | 字符串拼接 |
各字段定义
| 字段 | 取值 | 注意 |
|---|---|---|
METHOD | 全大写 HTTP 方法(OpenAPI 全部为 POST) | ASCII |
PATH | 请求路径含开头 /,不含查询串 | 不要 URL decode/encode |
RAW_QUERY | 查询串(不含 ?),无则空字符串 | 通常为空 |
TIMESTAMP | 与 X-Timestamp 头完全相同字符串 | ASCII |
NONCE | 与 X-Nonce 头完全相同 | ASCII |
BODY_SHA256_HEX | sha256(请求体字节) 的 lowercase hex | 64 字符 |
空 body 的 sha256
固定为 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855。 空对象 {} 的 sha256 是 44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a(注意区别)。
签名计算
text
signature = lowercase_hex( HMAC_SHA256( secret_bytes, signInput_bytes ) )Secret 编码
secret_bytes 是 secret 字符串的 UTF-8 字节(直接是 64 个 hex 字符的 ASCII 字节)。 不要把 hex decode 成 32 字节再做 HMAC。
签名长度固定为 64 字符 hex。
完整请求示例
http
POST /api/v1/openapi/card_headers/list HTTP/1.1
Host: api.coinepay.net
X-App-Id: cp_a1b2c3d4e5f6071829304a5b6c7d8e9f
X-Timestamp: 1714377600
X-Nonce: 8f7e6d5c4b3a29180a1b2c3d4e5f6071
X-Signature: 9b8e7f6d5c4b3a2918273645d4e3c2b1a0f9e8d7c6b5a4938271605f4e3d2c1b
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
Content-Type: application/json
Content-Length: 24
{"page":1,"page_size":20}防重放窗口
| 项 | 值 |
|---|---|
| 时间戳容差 | [server_now - 300s, server_now + 300s] (±5 分钟) |
| Nonce 唯一窗口 | 同一 (app_id, nonce) 在 10 分钟内不允许重复 |
| 推荐 Nonce 生成 | crypto/rand 16 字节 → 32 hex 字符 |
极端情况速查
| 场景 | 处理 |
|---|---|
body 是空对象 {} | BODY_SHA256_HEX = 44136fa3...8a |
| body 是空字符串 | 用空 body sha256 常量 e3b0c44...855 |
| body 含中文 | 按 UTF-8 字节计算 sha256 |
body 是数组 [1,2,3] | 按字节字面计算(key 排序、空格不影响哈希) |
| 网络中间件改 body | 签名失败;避免任何中间层改 body |
实现注意事项
客户端 stringify 必须确定性
不同语言的 JSON 序列化可能有键序、空格、转义差异。客户端 stringify 出来的字节是什么,就用什么计算 sha256,传给服务器的也是同一份字节。不要先 stringify 计算 hash、又用另一种序列化发送。
401 错误处理
服务器对所有鉴权失败原因(AppID 不存在 / 签名错 / 时间漂移 / 凭证禁用 / 用户不活跃)都返回相同的 openapi_invalid_credentials。这是反枚举设计,客户端无需根据具体原因区分。
排查步骤:
- 确认服务器时间与客户端时间差 < 5 分钟(用
date +%s对一下) - 打印
signInput的字节,逐行确认\n是0x0A、没有\r - 确认
X-Timestamp头与 signInput 中的TIMESTAMP完全一致(不是各算一次) - 确认
BODY_SHA256_HEX的 body 是实际发送的字节(不是 stringify 又改过的) - 重置一对凭证再试