签名参考

本页是 TEA1-HMAC-SHA256 请求签名方案的精确参考,该方案用于所有客户 API 端点。它包含规范字符串规则、冻结的测试向量,以及 Python、Go、Rust 的参考实现,方便你用已知答案校验自己的客户端。

有关流程讲解与 TypeScript 示例,请参阅 认证。

请求头

每个已签名请求都携带以下五个请求头:

请求头取值
x-api-key你的 access key id(公开)。
x-timestamp请求创建时的 Unix 时间(秒)。
x-nonce随机字符串,每个请求唯一(16 个随机字节的十六进制)。
x-body-hashhex(sha256(rawBody)) —— 精确请求体的哈希;空 body 仍要计算(sha256(""))。
x-signaturehex(HMAC-SHA256(secret, canonicalString))。

规范字符串

签名是对下面这个精确字符串(以 \n 连接)计算的 HMAC:

TEA1-HMAC-SHA256
<METHOD>
<path>
<canonicalQuery>
<timestamp>
<nonce>
<bodyHash>
  • <METHOD> —— 大写(GET / POST)。
  • <path> —— 完整路径,包含 /api/v1 前缀(例如 /api/v1/energy/quotes)。
  • <canonicalQuery> —— 按下述规则规范化的查询字符串;无查询字符串时为空。
  • <timestamp>、<nonce>、<bodyHash> —— 对应请求头的取值。

查询字符串规范化

  1. 按 & 分割,丢弃空片段。
  2. 解码每个键与值:+ → 空格,再做百分号解码。
  3. 先按键排序,再按值排序。
  4. 用 RFC 3986 百分号编码重新编码每个键与值 —— 只有 A–Z a–z 0–9 - . _ ~ 保持字面(! 会变成 %21)。

例如 page=2&note=hello!world 规范化为:

note=hello%21world&page=2

测试向量

下面的向量是冻结的已知答案 —— 你的客户端必须逐字节复现。它们使用 secret = sk_test_0123456789abcdef0123456789abcdef。

request-basic

字段取值
methodPOST
path/api/v1/energy/orders
rawQuery(空)
body{"amount":64400,"duration_minutes":60}
timestamp1727520000
nonce0123456789abcdef0123456789abcdef
bodyHash376b74a8d3e3b9fc48fcd6f49374a0ed495b7ac63dc285037376f55d54120e04
signaturec749398f7d8b55d6b7b9e2332d5b5ee20006e5e950ba873f0f9077357973ded3

规范字符串:

TEA1-HMAC-SHA256
POST
/api/v1/energy/orders

1727520000
0123456789abcdef0123456789abcdef
376b74a8d3e3b9fc48fcd6f49374a0ed495b7ac63dc285037376f55d54120e04

参考实现

每个实现都能复现 request-basic 向量。直接运行即可通过自检;对于真实请求,请用当前时钟生成 timestamp,用 CSPRNG 生成 nonce(os.urandom(16).hex() / crypto/rand / getrandom)。Python 与 Go 实现仅使用标准库;Rust 实现需要 hmac = "0.12"、sha2 = "0.10"、hex = "0.4"。

import hashlib, hmac, os, time
from urllib.parse import quote, unquote_plus

ALGORITHM = "TEA1-HMAC-SHA256"

def rfc3986_encode(value):
    # 仅保留 RFC 3986 非保留字符:A-Z a-z 0-9 - . _ ~
    return quote(value, safe="-._~")

def canonicalize_query(raw_query):
    if not raw_query:
        return ""
    pairs = []
    for part in raw_query.split("&"):
        if not part:
            continue
        k, _, v = part.partition("=")
        pairs.append((unquote_plus(k), unquote_plus(v)))
    pairs.sort(key=lambda kv: (kv[0], kv[1]))
    return "&".join(f"{rfc3986_encode(k)}={rfc3986_encode(v)}" for k, v in pairs)

def sign(secret, method, path, raw_query="", body="", timestamp=None, nonce=None):
    timestamp = timestamp or str(int(time.time()))
    nonce = nonce or os.urandom(16).hex()
    body_hash = hashlib.sha256(body.encode("utf-8")).hexdigest()
    canonical = "\n".join([ALGORITHM, method.upper(), path,
                           canonicalize_query(raw_query), timestamp, nonce, body_hash])
    signature = hmac.new(secret.encode("utf-8"), canonical.encode("utf-8"), hashlib.sha256).hexdigest()
    return {"x-timestamp": timestamp, "x-nonce": nonce,
            "x-body-hash": body_hash, "x-signature": signature}

# 对照已发布向量自检
headers = sign(
    "sk_test_0123456789abcdef0123456789abcdef",
    "POST", "/api/v1/energy/orders",
    body='{"amount":64400,"duration_minutes":60}',
    timestamp="1727520000", nonce="0123456789abcdef0123456789abcdef",
)
assert headers["x-body-hash"] == "376b74a8d3e3b9fc48fcd6f49374a0ed495b7ac63dc285037376f55d54120e04"
assert headers["x-signature"] == "c749398f7d8b55d6b7b9e2332d5b5ee20006e5e950ba873f0f9077357973ded3"