AUTHENTICATION

HMAC-SHA256 鉴权

派付的所有写接口都强制带 4 个 HMAC header,缺一不可。本章把签名算法、防重放策略、踩坑点全部讲清楚——读完应该再也不需要回头查。

关于 apiSecret 的安全保管

apiSecret 仅在创建应用时返回一次。请立即放进你自己的密钥管理系统(KMS / Vault / k8s Secret),不要提交到代码仓库、不要写在前端、不要打印到日志。 一旦泄漏,立即去后台「应用列表 → 重置密钥」轮换。

1. 四个必传 header

每一个鉴权请求都必须带齐这 4 个 header:

X-Api-Key
string required
应用的 apiKey,创建应用时由后台返回。沙箱环境前缀 pk_test_,正式环境前缀 pk_live_
例: pk_test_4f9d8ab1c0e74...
X-Timestamp
integer (UNIX 毫秒) required
请求发起时刻的 UNIX 毫秒时间戳(13 位整数)。与服务端时差不能超过 ±300000ms(5 分钟),否则返回 SIGN_TIMESTAMP_EXPIRED (1_007_002_002)
例: 1746450000000
X-Nonce
string required
本次请求的随机串,建议 UUID v4 或 16 字节 hex。同一 (X-Api-Key, X-Nonce) 在 10 分钟窗口内只允许出现一次,重复返回 SIGN_NONCE_DUPLICATE (1_007_002_003)
例: 8e3a1c2f7b6d4a90
X-Signature
string required
HMAC-SHA256 签名结果,小写 hex 编码。计算见下方算法。
例: 6e4a8c1f...(64 个字符)

2. 签名算法

stringToSign 由 5 段拼成,分隔符是换行符 \n(字面 LF,不是字符串 "\n"):

stringToSign = METHOD + "\n" + PATH + "\n" + X-Timestamp + "\n" + X-Nonce + "\n" + SHA256_hex(rawBody)
signature    = HMAC_SHA256(apiSecret, stringToSign).hex().toLowerCase()

各段说明

  • METHOD:HTTP 方法大写,如 GET / POST / PUT / DELETE
  • PATH:URL 的 path 部分,含 query string、https://host:port,如 /api/v1/wallet/address/create。query 参数参与签名。
  • X-Timestamp / X-Nonce:直接用 header 里的值(timestamp 是 UNIX 毫秒)。
  • SHA256_hex(rawBody):把实际发送的 HTTP body 的 UTF-8 字节原文做 SHA-256,输出 64 字符小写 hex。注意进签名串的是 body 的哈希,不是 body 原文。
  • GET 请求没有 body?那 rawBody 就是空字符串 "",它的 SHA-256 是固定值 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
  • body 只 JSON.stringify 一次就好,千万不要序列化两次——服务端按你发送的字节重算 SHA-256,多一个空格都会失败。

最常见的踩坑

  1. 秒级 timestamp(必须毫秒级,13 位)。
  2. body 原文直接拼进签名串(必须先对 body 做 SHA-256 再拼)。
  3. 漏掉 METHOD / PATH,或把 query string 拼进了 PATH
  4. 分隔符用了句号 . 而不是换行符 \n
  5. 序列化后的 JSON 对象而不是字符串去算哈希(导致 key 顺序 / 空白与发送时不一致)。
  6. HMAC 输出用了大写 hex(必须小写)。

3. 多语言示例

下面几段代码做的事完全一致:拿 secretmethodpathbody,吐出 (ts, nonce, sig)

签名
import crypto from 'node:crypto';

/**
 * stringToSign = METHOD + "\n" + PATH + "\n" + ts + "\n" + nonce + "\n" + SHA256_hex(rawBody)
 *   - METHOD 大写;PATH 是 URL path(不含 query、不含 host),如 /api/v1/wallet/address/create
 *   - ts 是 UNIX 毫秒(13 位)
 *   - rawBody 先做 SHA-256(小写 hex)再进签名串;GET 空 body 也要算(结果是固定值 e3b0c4...)
 */
function sign(secret, method, path, body = '') {
  const ts = Date.now().toString();                       // UNIX 毫秒
  const nonce = crypto.randomUUID().replace(/-/g, '');
  const bodyHash = crypto.createHash('sha256').update(body, 'utf8').digest('hex');
  const stringToSign = [method.toUpperCase(), path, ts, nonce, bodyHash].join('\n');
  const sig = crypto
    .createHmac('sha256', secret)
    .update(stringToSign, 'utf8')
    .digest('hex');                                        // 小写 hex
  return { ts, nonce, sig };
}

// 用法
const path = '/api/v1/wallet/address/create';
const body = JSON.stringify({ chainCode: 'ETH', externalUserId: 'user_1024' });
const { ts, nonce, sig } = sign(process.env.PQPA_SECRET, 'POST', path, body);

await fetch('https://api.pqpa.com' + path, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-Api-Key':    process.env.PQPA_API_KEY,
    'X-Timestamp':  ts,
    'X-Nonce':      nonce,
    'X-Signature':  sig,
  },
  body,
});

4. 防重放策略

派付服务端会针对每个鉴权请求做三层检查:

  1. 时间窗口|server_now - X-Timestamp| ≤ 300000ms(5 分钟)。超出直接返回 SIGN_TIMESTAMP_EXPIRED (1_007_002_002)。这要求你的服务器时钟通过 NTP 校准——NTP 偏移 5 分钟以上接派付会全部失败
  2. Nonce 去重:同一 (X-Api-Key, X-Nonce) 在 10 分钟窗口内只允许出现一次,重复返回 SIGN_NONCE_DUPLICATE (1_007_002_003)。 所以 nonce 不要用单调递增整数(万一你的进程重启 nonce 计数器从 0 开始就会报错),用 UUID v4 是最稳的。
  3. 签名校验:以上两步都过之后才做 HMAC 比较;任何一步失败都返回 401。

5. 排错

99% 的"我签名怎么都对不上"问题是 rawBody 不一致。 建议在客户端集成一个自检步骤——在发送前用同一份 secret + body 自己算一遍签名, 和将要发送出去的签名比对:

本地自检
// 把这段加到你接入的代码里,签完拍上去之前先在本机校验一遍
import crypto from 'node:crypto';

function verify(secret, method, path, ts, nonce, body, sig) {
  const bodyHash = crypto.createHash('sha256').update(body, 'utf8').digest('hex');
  const stringToSign = [method.toUpperCase(), path, ts, nonce, bodyHash].join('\n');
  const expected = crypto
    .createHmac('sha256', secret)
    .update(stringToSign, 'utf8')
    .digest('hex');
  if (expected !== sig) {
    console.error('[PQPA] 签名不一致!');
    console.error('  expected:', expected);
    console.error('  actual:  ', sig);
    console.error('  stringToSign:', JSON.stringify(stringToSign));
    return false;
  }
  return true;
}

还是搞不定?

请求的所有 header + HTTP body 的 hex dump (前 200 字节够了)+ X-Trace-Id 发给我们,我们能在服务端日志里反查到拒绝原因。 提交工单 时也带上。

6. 密钥轮换

派付支持双密钥并行过渡期,让你能无停机轮换:

  1. 后台「应用列表 → 重置密钥」时,会同时返回 newSecret + oldSecret
  2. 派付服务端在接下来 24 小时同时接受这两个 secret 签名出来的请求。
  3. 你在这 24 小时窗口内把所有上游服务的 secret 替换成 newSecret
  4. 24 小时后 oldSecret 自动失效。

下一步

🎯 学会签名后,建议直接去 创建充值地址 跑一遍真实请求——这是验证签名是否正确的最快路径。