跳转到正文
生产环境文档契约 1.5.169
正在核对线上版本…

鉴权与签名 ​

平台的接口安全分两层:API Key 证明「你是哪个渠道」;HMAC 签名证明 「请求内容没被改过,也不是别人截获后原样重发的」。

各接口的要求 ​

接口组要求
/api/v1/channel/*(授权、订单、产品查询)X-Channel-Key;下载接口按下方签名链接规则
/api/channel/data/*(数据、报告状态)始终需要 Key;目标环境可配置为强制签名
/api/v2/channel/invoices/*(游标、分片包)必须 Key + 时间戳 + Nonce + 签名
PDF、HTML 文件、原始文件及分片下载直接使用完整签名链接,无需渠道请求头;原始文件还包含 authorization_id
HTML 报告页拿到链接里的令牌即可访问,不带请求头

原始文件从 API 1.5.29 起返回签名链接;旧版不带签名的原始文件地址仍需 X-Channel-Key。签名参数无效时不会回退到 Key,见原始文件下载。

签名头要么全给,要么全不给

数据接口若提交了任一签名头,三项签名头必须齐全, 否则返回 401 CHANNEL_SIGNATURE_REQUIRED。

请求头 ​

http
X-Channel-Key: rpa_channel_xxx
X-Channel-Timestamp: 1786490400
X-Channel-Nonce: 0b9c1de0-8f3a-4c21-9a77-2b1f0e5c6d84
X-Channel-Signature: sha256=9f1c0d1a2b3c…
头约束
X-Channel-TimestampUnix 秒,与服务端时间误差不得超过 300 秒
X-Channel-Nonce一次性随机串,每个请求都要换新的;最长 128 字符,非空,10 分钟内不得重复
X-Channel-Signature十六进制小写,可带 sha256= 前缀

签名算法 ​

把请求的关键信息按固定顺序拼成一个字符串(下称 canonical,即「待签名字符串」), 再用 Callback Secret 对它做 HMAC-SHA256:

text
canonical = timestamp + "\n"
          + nonce     + "\n"
          + METHOD    + "\n"
          + path      + "\n"
          + raw_query + "\n"
          + sha256(raw_body)

signature = HMAC-SHA256(callback_secret, canonical)
元素取值
timestamp与 X-Channel-Timestamp 完全相同的字符串
nonce与 X-Channel-Nonce 完全相同
METHOD大写 HTTP 方法,如 POST
pathURL 路径,如 /api/v2/channel/invoices/cursor,不含域名和查询串
raw_query原始查询串,不含 ?;没有查询参数时为空串
sha256(raw_body)请求体实际发送字节的 SHA-256 十六进制小写;GET 为空串的哈希

必须基于实际发送的字节

先序列化出 body 字节,用同一份字节计算哈希并发送。 「先算签名再重新序列化」会因键顺序、空格、转义差异导致签名失败。

参考实现 ​

Python 签名示例 ​

python
import hashlib
import hmac
import json
import time
import uuid

import requests


def signed_post(base_url: str, path: str, payload: dict, key: str, secret: str):
    # 只序列化一次,签名和发送使用同一份字节
    body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
    timestamp = str(int(time.time()))
    nonce = uuid.uuid4().hex

    canonical = "\n".join(
        [timestamp, nonce, "POST", path, "", hashlib.sha256(body).hexdigest()]
    )
    signature = hmac.new(
        secret.encode("utf-8"), canonical.encode("utf-8"), hashlib.sha256
    ).hexdigest()

    return requests.post(
        base_url + path,
        data=body,
        headers={
            "X-Channel-Key": key,
            "X-Channel-Timestamp": timestamp,
            "X-Channel-Nonce": nonce,
            "X-Channel-Signature": f"sha256={signature}",
            "Content-Type": "application/json; charset=utf-8",
        },
        timeout=60,
    )

Node.js 签名示例 ​

js
import { createHash, createHmac, randomUUID } from 'node:crypto'

export async function signedPost(baseUrl, path, payload, key, secret) {
  const body = Buffer.from(JSON.stringify(payload), 'utf8')
  const timestamp = String(Math.floor(Date.now() / 1000))
  const nonce = randomUUID()

  const canonical = [
    timestamp,
    nonce,
    'POST',
    path,
    '',
    createHash('sha256').update(body).digest('hex'),
  ].join('\n')

  const signature = createHmac('sha256', secret).update(canonical).digest('hex')

  return fetch(baseUrl + path, {
    method: 'POST',
    body,
    headers: {
      'X-Channel-Key': key,
      'X-Channel-Timestamp': timestamp,
      'X-Channel-Nonce': nonce,
      'X-Channel-Signature': `sha256=${signature}`,
      'Content-Type': 'application/json; charset=utf-8',
    },
  })
}

Java 签名示例 ​

java
String body = objectMapper.writeValueAsString(payload);
byte[] bodyBytes = body.getBytes(StandardCharsets.UTF_8);

String timestamp = String.valueOf(Instant.now().getEpochSecond());
String nonce = UUID.randomUUID().toString();
String bodyHash = Hex.encodeHexString(
    MessageDigest.getInstance("SHA-256").digest(bodyBytes));

String canonical = String.join("\n",
    timestamp, nonce, "POST", path, "", bodyHash);

Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String signature = Hex.encodeHexString(
    mac.doFinal(canonical.getBytes(StandardCharsets.UTF_8)));

防止请求被重放 ​

「重放」指请求在传输中被截获后,被人原样再发一遍。防线就是 Nonce: 平台会把用过的 Nonce 记住 10 分钟,同一渠道在这段时间内重复使用同一个 Nonce, 返回 409 CHANNEL_REQUEST_REPLAYED。

Nonce 生成

用 UUID v4 或 16 字节以上的密码学随机数。不要用时间戳、自增 ID 或请求内容哈希 —— 重试同一请求时会撞上重放拒绝。

排查签名失败 ​

按顺序检查:

现象检查点
CHANNEL_TIMESTAMP_EXPIRED服务器时钟是否与 NTP 同步,误差是否超过 300 秒
INVALID_CHANNEL_TIMESTAMP时间戳是否为整数秒(不是毫秒、不是 ISO 字符串)
INVALID_CHANNEL_NONCE是否为空或超过 128 字符
CHANNEL_REQUEST_REPLAYED重试时是否换了新 Nonce
INVALID_CHANNEL_SIGNATUREcanonical 六行是否齐全、raw_query 是否误带 ?、body 是否被重新序列化
CHANNEL_SIGNATURE_REQUIRED三项签名头是否齐全
canonical 逐行核对

用一个已知请求手工拼一遍:

text
1786490400
0b9c1de08f3a4c219a772b1f0e5c6d84
POST
/api/v2/channel/invoices/cursor

44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a

第 5 行是空行(没有查询参数),第 6 行是 body 的 SHA-256。 把这段字符串和 Callback Secret 一起做 HMAC-SHA256,结果应与请求头一致。

验证回调的签名 ​

方向相反:平台用 Callback Secret 对回调 Body 原始字节计算 HMAC-SHA256, 放在 X-RPA-Signature。客户端必须基于原始字节验证签名, 见回调通知。

文档契约 API 1.5.169